baclassic

Components for the classic BombSquad experience.

This package/feature-set contains functionality related to the classic BombSquad experience. Note that much legacy BombSquad code is still a bit tangled and thus this feature-set is largely inseperable from bascenev1 and bauiv1. Future feature-sets will be designed in a more modular way.

 1# Released under the MIT License. See LICENSE for details.
 2#
 3"""Components for the classic BombSquad experience.
 4
 5This package/feature-set contains functionality related to the classic
 6BombSquad experience. Note that much legacy BombSquad code is still a
 7bit tangled and thus this feature-set is largely inseperable from
 8:mod:`bascenev1` and :mod:`bauiv1`. Future feature-sets will be
 9designed in a more modular way.
10"""
11
12# ba_meta require api 9
13
14# Note: Code relying on classic should import things from here *only*
15# for type-checking and use the versions in ba*.app.classic at runtime;
16# that way type-checking will cleanly cover the classic-not-present case
17# (ba*.app.classic being None).
18import logging
19
20from efro.util import set_canonical_module_names
21
22from baclassic._appmode import ClassicAppMode
23from baclassic._appsubsystem import ClassicAppSubsystem
24from baclassic._achievement import Achievement, AchievementSubsystem
25from baclassic._chest import (
26    ChestAppearanceDisplayInfo,
27    CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT,
28    CHEST_APPEARANCE_DISPLAY_INFOS,
29)
30from baclassic._displayitem import show_display_item
31
32__all__ = [
33    'ChestAppearanceDisplayInfo',
34    'CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT',
35    'CHEST_APPEARANCE_DISPLAY_INFOS',
36    'ClassicAppMode',
37    'ClassicAppSubsystem',
38    'Achievement',
39    'AchievementSubsystem',
40    'show_display_item',
41]
42
43# We want stuff here to show up as packagename.Foo instead of
44# packagename._submodule.Foo.
45set_canonical_module_names(globals())
46
47# Sanity check: we want to keep ballistica's dependencies and
48# bootstrapping order clearly defined; let's check a few particular
49# modules to make sure they never directly or indirectly import us
50# before their own execs complete.
51if __debug__:
52    for _mdl in 'babase', '_babase':
53        if not hasattr(__import__(_mdl), '_REACHED_END_OF_MODULE'):
54            logging.warning(
55                '%s was imported before %s finished importing;'
56                ' should not happen.',
57                __name__,
58                _mdl,
59            )
@dataclass
class ChestAppearanceDisplayInfo:
16@dataclass
17class ChestAppearanceDisplayInfo:
18    """Info about how to locally display chest appearances."""
19
20    # NOTE TO SELF: Don't rename these attrs; the C++ layer is hard
21    # coded to look for them.
22
23    texclosed: str
24    texclosedtint: str
25    texopen: str
26    texopentint: str
27    color: tuple[float, float, float]
28    tint: tuple[float, float, float]
29    tint2: tuple[float, float, float]

Info about how to locally display chest appearances.

ChestAppearanceDisplayInfo( texclosed: str, texclosedtint: str, texopen: str, texopentint: str, color: tuple[float, float, float], tint: tuple[float, float, float], tint2: tuple[float, float, float])
texclosed: str
texclosedtint: str
texopen: str
texopentint: str
color: tuple[float, float, float]
tint: tuple[float, float, float]
tint2: tuple[float, float, float]
CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT = ChestAppearanceDisplayInfo(texclosed='chestIcon', texclosedtint='chestIconTint', texopen='chestOpenIcon', texopentint='chestOpenIconTint', color=(1, 1, 1), tint=(1, 1, 1), tint2=(1, 1, 1))
CHEST_APPEARANCE_DISPLAY_INFOS = {<ClassicChestAppearance.L2: 'l2'>: ChestAppearanceDisplayInfo(texclosed='chestIcon', texclosedtint='chestIconTint', texopen='chestOpenIcon', texopentint='chestOpenIconTint', color=(0.8, 1.0, 0.93), tint=(0.65, 1.0, 0.8), tint2=(0.65, 1.0, 0.8)), <ClassicChestAppearance.L3: 'l3'>: ChestAppearanceDisplayInfo(texclosed='chestIcon', texclosedtint='chestIconTint', texopen='chestOpenIcon', texopentint='chestOpenIconTint', color=(0.75, 0.9, 1.3), tint=(0.7, 1, 1.9), tint2=(0.7, 1, 1.9)), <ClassicChestAppearance.L4: 'l4'>: ChestAppearanceDisplayInfo(texclosed='chestIcon', texclosedtint='chestIconTint', texopen='chestOpenIcon', texopentint='chestOpenIconTint', color=(0.7, 1.0, 1.4), tint=(1.4, 1.6, 2.0), tint2=(1.4, 1.6, 2.0)), <ClassicChestAppearance.L5: 'l5'>: ChestAppearanceDisplayInfo(texclosed='chestIcon', texclosedtint='chestIconTint', texopen='chestOpenIcon', texopentint='chestOpenIconTint', color=(0.75, 0.5, 2.4), tint=(1.0, 0.8, 0.0), tint2=(1.0, 0.8, 0.0)), <ClassicChestAppearance.L6: 'l6'>: ChestAppearanceDisplayInfo(texclosed='chestIcon', texclosedtint='chestIconTint', texopen='chestOpenIcon', texopentint='chestOpenIconTint', color=(1.1, 0.8, 0.0), tint=(2, 2, 2), tint2=(2, 2, 2))}
class ClassicAppMode(babase._appmode.AppMode):
 30class ClassicAppMode(babase.AppMode):
 31    """AppMode for the classic BombSquad experience."""
 32
 33    _LEAGUE_VIS_VALS_CONFIG_KEY = 'ClassicLeagueVisVals'
 34
 35    def __init__(self) -> None:
 36        self._on_primary_account_changed_callback: (
 37            CallbackRegistration | None
 38        ) = None
 39        self._on_connectivity_changed_callback: CallbackRegistration | None = (
 40            None
 41        )
 42        self._test_sub: babase.CloudSubscription | None = None
 43        self._account_data_sub: babase.CloudSubscription | None = None
 44
 45        self._have_account_values = False
 46        self._have_connectivity = False
 47        self._current_account_id: str | None = None
 48        self._should_restore_account_display_state = False
 49
 50        self._purchase_ui_pause: bauiv1.RootUIUpdatePause | None = None
 51        self._last_tokens_value = 0
 52
 53    @override
 54    @classmethod
 55    def get_app_experience(cls) -> AppExperience:
 56        return AppExperience.MELEE
 57
 58    @override
 59    @classmethod
 60    def _can_handle_intent(cls, intent: babase.AppIntent) -> bool:
 61        # We support default and exec intents currently.
 62        return isinstance(
 63            intent, babase.AppIntentExec | babase.AppIntentDefault
 64        )
 65
 66    @override
 67    def handle_intent(self, intent: babase.AppIntent) -> None:
 68        if isinstance(intent, babase.AppIntentExec):
 69            _baclassic.classic_app_mode_handle_app_intent_exec(intent.code)
 70            return
 71        assert isinstance(intent, babase.AppIntentDefault)
 72        _baclassic.classic_app_mode_handle_app_intent_default()
 73
 74    @override
 75    def on_activate(self) -> None:
 76
 77        # Let the native layer do its thing.
 78        _baclassic.classic_app_mode_activate()
 79
 80        app = babase.app
 81        plus = app.plus
 82        assert plus is not None
 83
 84        # Wire up the root ui to do what we want.
 85        ui = app.ui_v1
 86        ui.root_ui_calls[ui.RootUIElement.ACCOUNT_BUTTON] = (
 87            self._root_ui_account_press
 88        )
 89        ui.root_ui_calls[ui.RootUIElement.MENU_BUTTON] = (
 90            self._root_ui_menu_press
 91        )
 92        ui.root_ui_calls[ui.RootUIElement.SQUAD_BUTTON] = (
 93            self._root_ui_squad_press
 94        )
 95        ui.root_ui_calls[ui.RootUIElement.SETTINGS_BUTTON] = (
 96            self._root_ui_settings_press
 97        )
 98        ui.root_ui_calls[ui.RootUIElement.STORE_BUTTON] = (
 99            self._root_ui_store_press
100        )
101        ui.root_ui_calls[ui.RootUIElement.INVENTORY_BUTTON] = (
102            self._root_ui_inventory_press
103        )
104        ui.root_ui_calls[ui.RootUIElement.GET_TOKENS_BUTTON] = (
105            self._root_ui_get_tokens_press
106        )
107        ui.root_ui_calls[ui.RootUIElement.INBOX_BUTTON] = (
108            self._root_ui_inbox_press
109        )
110        ui.root_ui_calls[ui.RootUIElement.TICKETS_METER] = (
111            self._root_ui_tickets_meter_press
112        )
113        ui.root_ui_calls[ui.RootUIElement.TOKENS_METER] = (
114            self._root_ui_tokens_meter_press
115        )
116        ui.root_ui_calls[ui.RootUIElement.TROPHY_METER] = (
117            self._root_ui_trophy_meter_press
118        )
119        ui.root_ui_calls[ui.RootUIElement.LEVEL_METER] = (
120            self._root_ui_level_meter_press
121        )
122        ui.root_ui_calls[ui.RootUIElement.ACHIEVEMENTS_BUTTON] = (
123            self._root_ui_achievements_press
124        )
125        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_0] = partial(
126            self._root_ui_chest_slot_pressed, 0
127        )
128        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_1] = partial(
129            self._root_ui_chest_slot_pressed, 1
130        )
131        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_2] = partial(
132            self._root_ui_chest_slot_pressed, 2
133        )
134        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_3] = partial(
135            self._root_ui_chest_slot_pressed, 3
136        )
137
138        # We want to be informed when connectivity changes.
139        self._on_connectivity_changed_callback = (
140            plus.cloud.on_connectivity_changed_callbacks.register(
141                self._update_for_connectivity_change
142            )
143        )
144        # We want to be informed when primary account changes.
145        self._on_primary_account_changed_callback = (
146            plus.accounts.on_primary_account_changed_callbacks.register(
147                self._update_for_primary_account
148            )
149        )
150        # Establish subscriptions/etc. for any current primary account.
151        self._update_for_primary_account(plus.accounts.primary)
152        self._have_connectivity = plus.cloud.is_connected()
153        self._update_for_connectivity_change(self._have_connectivity)
154
155    @override
156    def on_deactivate(self) -> None:
157
158        classic = babase.app.classic
159
160        # Store latest league vis vals for any active account.
161        self._save_account_display_state()
162
163        # Stop being informed of account changes.
164        self._on_primary_account_changed_callback = None
165
166        # Cancel any ui-pause we may have had going.
167        self._purchase_ui_pause = None
168
169        # Remove anything following any current account.
170        self._update_for_primary_account(None)
171
172        # Save where we were in the UI so we return there next time.
173        if classic is not None:
174            classic.save_ui_state()
175
176        # Let the native layer do its thing.
177        _baclassic.classic_app_mode_deactivate()
178
179    @override
180    def on_app_active_changed(self) -> None:
181        if not babase.app.active:
182            # If we've gone inactive, bring up the main menu, which has the
183            # side effect of pausing the action (when possible).
184            babase.invoke_main_menu()
185
186            # Also store any league vis state for the active account.
187            # this may be our last chance to do this on mobile.
188            self._save_account_display_state()
189
190    @override
191    def on_purchase_process_begin(
192        self, item_id: str, user_initiated: bool
193    ) -> None:
194
195        # Do the default thing (announces 'updating account...')
196        super().on_purchase_process_begin(
197            item_id=item_id, user_initiated=user_initiated
198        )
199
200        # Pause the root ui so stuff like token counts don't change
201        # automatically, allowing us to animate them. Note that we
202        # need to explicitly kill this pause if we are deactivated since
203        # we wouldn't get the on_purchase_process_end() call; the next
204        # app-mode would.
205        self._purchase_ui_pause = bauiv1.RootUIUpdatePause()
206
207        # Also grab our last known token count here to plug into animations.
208        # We need to do this here before the purchase gets submitted so that
209        # we know we're seeing the old value.
210        assert babase.app.classic is not None
211        self._last_tokens_value = babase.app.classic.tokens
212
213    @override
214    def on_purchase_process_end(
215        self, item_id: str, user_initiated: bool, applied: bool
216    ) -> None:
217
218        # Let the UI auto-update again after any animations we may apply
219        # here.
220        self._purchase_ui_pause = None
221
222        # Ignore user_initiated; we want to announce newly applied stuff
223        # even if it was from a different launch or client or whatever.
224        del user_initiated
225
226        # If the purchase wasn't applied, do nothing. This likely means it
227        # was redundant or something else harmless.
228        if not applied:
229            return
230
231        if item_id.startswith('tokens'):
232            if item_id == 'tokens1':
233                tokens = bacommon.bs.TOKENS1_COUNT
234                tokens_str = str(tokens)
235                anim_time = 2.0
236            elif item_id == 'tokens2':
237                tokens = bacommon.bs.TOKENS2_COUNT
238                tokens_str = str(tokens)
239                anim_time = 2.5
240            elif item_id == 'tokens3':
241                tokens = bacommon.bs.TOKENS3_COUNT
242                tokens_str = str(tokens)
243                anim_time = 3.0
244            elif item_id == 'tokens4':
245                tokens = bacommon.bs.TOKENS4_COUNT
246                tokens_str = str(tokens)
247                anim_time = 3.5
248            else:
249                tokens = 0
250                tokens_str = '???'
251                anim_time = 2.5
252                logging.warning(
253                    'Unhandled item_id in on_purchase_process_end: %s', item_id
254                )
255
256            assert babase.app.classic is not None
257            effects: list[bacommon.bs.ClientEffect] = [
258                bacommon.bs.ClientEffectTokensAnimation(
259                    duration=anim_time,
260                    startvalue=self._last_tokens_value,
261                    endvalue=self._last_tokens_value + tokens,
262                ),
263                bacommon.bs.ClientEffectDelay(anim_time),
264                bacommon.bs.ClientEffectScreenMessage(
265                    message='You got ${COUNT} tokens!',
266                    subs=['${COUNT}', tokens_str],
267                    color=(0, 1, 0),
268                ),
269                bacommon.bs.ClientEffectSound(
270                    sound=bacommon.bs.ClientEffectSound.Sound.CASH_REGISTER
271                ),
272            ]
273            babase.app.classic.run_bs_client_effects(effects)
274
275        elif item_id.startswith('gold_pass'):
276            babase.screenmessage(
277                babase.Lstr(
278                    translate=('serverResponses', 'You got a ${ITEM}!'),
279                    subs=[
280                        (
281                            '${ITEM}',
282                            babase.Lstr(resource='goldPass.goldPassText'),
283                        )
284                    ],
285                ),
286                color=(0, 1, 0),
287            )
288            if babase.asset_loads_allowed():
289                babase.getsimplesound('cashRegister').play()
290
291        else:
292
293            # Fallback: simply announce item id.
294            logging.warning(
295                'on_purchase_process_end got unexpected item_id: %s.', item_id
296            )
297            babase.screenmessage(
298                babase.Lstr(
299                    translate=('serverResponses', 'You got a ${ITEM}!'),
300                    subs=[('${ITEM}', item_id)],
301                ),
302                color=(0, 1, 0),
303            )
304            if babase.asset_loads_allowed():
305                babase.getsimplesound('cashRegister').play()
306
307    def on_engine_will_reset(self) -> None:
308        """Called just before classic resets the engine.
309
310        This happens at various times such as session switches.
311        """
312
313        self._save_account_display_state()
314
315    def on_engine_did_reset(self) -> None:
316        """Called just after classic resets the engine.
317
318        This happens at various times such as session switches.
319        """
320
321        # Restore any old league vis state we had; this allows the user
322        # to see animations for league improvements or other changes
323        # that have occurred since the last time we were visible.
324        self._restore_account_display_state()
325
326    def _update_for_primary_account(
327        self, account: babase.AccountV2Handle | None
328    ) -> None:
329        """Update subscriptions/etc. for a new primary account state."""
330        assert babase.in_logic_thread()
331        plus = babase.app.plus
332
333        assert plus is not None
334
335        classic = babase.app.classic
336        assert classic is not None
337
338        if account is not None:
339            self._current_account_id = account.accountid
340            babase.set_ui_account_state(True, account.tag)
341            self._should_restore_account_display_state = True
342        else:
343            # If we had an account, save any existing league vis state
344            # so we'll properly animate to new values the next time we
345            # sign in.
346            self._save_account_display_state()
347
348            self._current_account_id = None
349            babase.set_ui_account_state(False)
350            self._should_restore_account_display_state = False
351
352        # For testing subscription functionality.
353        if os.environ.get('BA_SUBSCRIPTION_TEST') == '1':
354            if account is None:
355                self._test_sub = None
356            else:
357                with account:
358                    self._test_sub = plus.cloud.subscribe_test(
359                        self._on_sub_test_update
360                    )
361        else:
362            self._test_sub = None
363
364        if account is None:
365            classic.gold_pass = False
366            classic.tokens = 0
367            classic.chest_dock_full = False
368            classic.remove_ads = False
369            self._account_data_sub = None
370            _baclassic.set_root_ui_account_values(
371                tickets=-1,
372                tokens=-1,
373                league_type='',
374                league_number=-1,
375                league_rank=-1,
376                achievements_percent_text='',
377                level_text='',
378                xp_text='',
379                inbox_count=-1,
380                inbox_count_is_max=False,
381                gold_pass=False,
382                chest_0_appearance='',
383                chest_1_appearance='',
384                chest_2_appearance='',
385                chest_3_appearance='',
386                chest_0_unlock_time=-1.0,
387                chest_1_unlock_time=-1.0,
388                chest_2_unlock_time=-1.0,
389                chest_3_unlock_time=-1.0,
390                chest_0_ad_allow_time=-1.0,
391                chest_1_ad_allow_time=-1.0,
392                chest_2_ad_allow_time=-1.0,
393                chest_3_ad_allow_time=-1.0,
394            )
395            self._have_account_values = False
396            self._update_ui_live_state()
397
398        else:
399            with account:
400                self._account_data_sub = (
401                    plus.cloud.subscribe_classic_account_data(
402                        self._on_classic_account_data_change
403                    )
404                )
405
406    def _update_for_connectivity_change(self, connected: bool) -> None:
407        """Update when the app's connectivity state changes."""
408        self._have_connectivity = connected
409        self._update_ui_live_state()
410
411    def _update_ui_live_state(self) -> None:
412        # We want to show ui elements faded if we don't have a live
413        # connection to the master-server OR if we haven't received a
414        # set of account values from them yet. If we just plug in raw
415        # connectivity state here we get UI stuff un-fading a moment or
416        # two before values appear (since the subscriptions have not
417        # sent us any values yet) which looks odd.
418        _baclassic.set_have_live_account_values(
419            self._have_connectivity and self._have_account_values
420        )
421
422    def _on_sub_test_update(self, val: int | None) -> None:
423        print(f'GOT SUB TEST UPDATE: {val}')
424
425    def _on_classic_account_data_change(
426        self, val: bacommon.bs.ClassicAccountLiveData
427    ) -> None:
428        achp = round(val.achievements / max(val.achievements_total, 1) * 100.0)
429        # ibc = str(val.inbox_count)
430        # if val.inbox_count_is_max:
431        #     ibc += '+'
432
433        chest0 = val.chests.get('0')
434        chest1 = val.chests.get('1')
435        chest2 = val.chests.get('2')
436        chest3 = val.chests.get('3')
437
438        # Keep a few handy values on classic updated with the latest
439        # data.
440        classic = babase.app.classic
441        assert classic is not None
442        classic.remove_ads = val.remove_ads
443        classic.gold_pass = val.gold_pass
444        classic.tokens = val.tokens
445        classic.chest_dock_full = (
446            chest0 is not None
447            and chest1 is not None
448            and chest2 is not None
449            and chest3 is not None
450        )
451
452        _baclassic.set_root_ui_account_values(
453            tickets=val.tickets,
454            tokens=val.tokens,
455            league_type=(
456                '' if val.league_type is None else val.league_type.value
457            ),
458            league_number=(-1 if val.league_num is None else val.league_num),
459            league_rank=(-1 if val.league_rank is None else val.league_rank),
460            achievements_percent_text=f'{achp}%',
461            level_text=str(val.level),
462            xp_text=f'{val.xp}/{val.xpmax}',
463            inbox_count=val.inbox_count,
464            inbox_count_is_max=val.inbox_count_is_max,
465            gold_pass=val.gold_pass,
466            chest_0_appearance=(
467                '' if chest0 is None else chest0.appearance.value
468            ),
469            chest_1_appearance=(
470                '' if chest1 is None else chest1.appearance.value
471            ),
472            chest_2_appearance=(
473                '' if chest2 is None else chest2.appearance.value
474            ),
475            chest_3_appearance=(
476                '' if chest3 is None else chest3.appearance.value
477            ),
478            chest_0_unlock_time=(
479                -1.0 if chest0 is None else chest0.unlock_time.timestamp()
480            ),
481            chest_1_unlock_time=(
482                -1.0 if chest1 is None else chest1.unlock_time.timestamp()
483            ),
484            chest_2_unlock_time=(
485                -1.0 if chest2 is None else chest2.unlock_time.timestamp()
486            ),
487            chest_3_unlock_time=(
488                -1.0 if chest3 is None else chest3.unlock_time.timestamp()
489            ),
490            chest_0_ad_allow_time=(
491                -1.0
492                if chest0 is None or chest0.ad_allow_time is None
493                else chest0.ad_allow_time.timestamp()
494            ),
495            chest_1_ad_allow_time=(
496                -1.0
497                if chest1 is None or chest1.ad_allow_time is None
498                else chest1.ad_allow_time.timestamp()
499            ),
500            chest_2_ad_allow_time=(
501                -1.0
502                if chest2 is None or chest2.ad_allow_time is None
503                else chest2.ad_allow_time.timestamp()
504            ),
505            chest_3_ad_allow_time=(
506                -1.0
507                if chest3 is None or chest3.ad_allow_time is None
508                else chest3.ad_allow_time.timestamp()
509            ),
510        )
511        if self._should_restore_account_display_state:
512            # If we have a previous display-state for this account,
513            # restore it. This will cause us to animate or otherwise
514            # display league changes that have occurred since we were
515            # last visible. Note we need to do this *after* setting real
516            # vals so there is a current state to animate to.
517            self._restore_account_display_state()
518            self._should_restore_account_display_state = False
519
520        # Note that we have values and updated faded state accordingly.
521        self._have_account_values = True
522        self._update_ui_live_state()
523
524    def _root_ui_menu_press(self) -> None:
525        from babase import push_back_press
526
527        ui = babase.app.ui_v1
528
529        # If *any* main-window is up, kill it and resume play.
530        old_window = ui.get_main_window()
531        if old_window is not None:
532
533            classic = babase.app.classic
534            assert classic is not None
535            classic.resume()
536
537            ui.clear_main_window()
538            return
539
540        # Otherwise
541        push_back_press()
542
543    def _root_ui_account_press(self) -> None:
544        from bauiv1lib.account.settings import AccountSettingsWindow
545
546        self._auxiliary_window_nav(
547            win_type=AccountSettingsWindow,
548            win_create_call=lambda: AccountSettingsWindow(
549                origin_widget=bauiv1.get_special_widget('account_button')
550            ),
551        )
552
553    def _root_ui_squad_press(self) -> None:
554        btn = bauiv1.get_special_widget('squad_button')
555        center = btn.get_screen_space_center()
556        if bauiv1.app.classic is not None:
557            bauiv1.app.classic.party_icon_activate(center)
558        else:
559            logging.warning('party_icon_activate: no classic.')
560
561    def _root_ui_settings_press(self) -> None:
562        from bauiv1lib.settings.allsettings import AllSettingsWindow
563
564        self._auxiliary_window_nav(
565            win_type=AllSettingsWindow,
566            win_create_call=lambda: AllSettingsWindow(
567                origin_widget=bauiv1.get_special_widget('settings_button')
568            ),
569        )
570
571    def _auxiliary_window_nav(
572        self,
573        win_type: type[bauiv1.MainWindow],
574        win_create_call: Callable[[], bauiv1.MainWindow],
575    ) -> None:
576        """Navigate to or away from an Auxiliary window.
577
578        Auxiliary windows can be thought of as 'side quests' in the
579        window hierarchy; places such as settings windows or league
580        ranking windows that the user might want to visit without losing
581        their place in the regular hierarchy.
582        """
583        # pylint: disable=unidiomatic-typecheck
584
585        ui = babase.app.ui_v1
586
587        current_main_window = ui.get_main_window()
588
589        # Scan our ancestors for auxiliary states matching our type as
590        # well as auxiliary states in general.
591        aux_matching_state: bauiv1.MainWindowState | None = None
592        aux_state: bauiv1.MainWindowState | None = None
593
594        if current_main_window is None:
595            raise RuntimeError(
596                'Not currently handling no-top-level-window case.'
597            )
598
599        state = current_main_window.main_window_back_state
600        while state is not None:
601            assert state.window_type is not None
602            if state.is_auxiliary:
603                if state.window_type is win_type:
604                    aux_matching_state = state
605                else:
606                    aux_state = state
607
608            state = state.parent
609
610        # If there's an ancestor auxiliary window-state matching our
611        # type, back out past it (example: poking settings, navigating
612        # down a level or two, and then poking settings again should
613        # back out of settings).
614        if aux_matching_state is not None:
615            current_main_window.main_window_back_state = (
616                aux_matching_state.parent
617            )
618            current_main_window.main_window_back()
619            return
620
621        # If there's an ancestory auxiliary state *not* matching our
622        # type, crop the state and swap in our new auxiliary UI
623        # (example: poking settings, then poking account, then poking
624        # back should end up where things were before the settings
625        # poke).
626        if aux_state is not None:
627            # Blow away the window stack and build a fresh one.
628            ui.clear_main_window()
629            ui.set_main_window(
630                win_create_call(),
631                from_window=False,  # Disable from-check.
632                back_state=aux_state.parent,
633                suppress_warning=True,
634                is_auxiliary=True,
635            )
636            return
637
638        # Ok, no auxiliary states found. Now if current window is
639        # auxiliary and the type matches, simply do a back.
640        if (
641            current_main_window.main_window_is_auxiliary
642            and type(current_main_window) is win_type
643        ):
644            current_main_window.main_window_back()
645            return
646
647        # If current window is auxiliary but type doesn't match,
648        # swap it out for our new auxiliary UI.
649        if current_main_window.main_window_is_auxiliary:
650            ui.clear_main_window()
651            ui.set_main_window(
652                win_create_call(),
653                from_window=False,  # Disable from-check.
654                back_state=current_main_window.main_window_back_state,
655                suppress_warning=True,
656                is_auxiliary=True,
657            )
658            return
659
660        # Ok, no existing auxiliary stuff was found period. Just
661        # navigate forward to this UI.
662        current_main_window.main_window_replace(
663            win_create_call(), is_auxiliary=True
664        )
665
666    def _root_ui_achievements_press(self) -> None:
667        from bauiv1lib.achievements import AchievementsWindow
668
669        if not self._ensure_signed_in_v1():
670            return
671
672        wait_for_connectivity(
673            on_connected=lambda: self._auxiliary_window_nav(
674                win_type=AchievementsWindow,
675                win_create_call=lambda: AchievementsWindow(
676                    origin_widget=bauiv1.get_special_widget(
677                        'achievements_button'
678                    )
679                ),
680            )
681        )
682
683    def _root_ui_inbox_press(self) -> None:
684        from bauiv1lib.inbox import InboxWindow
685
686        if not self._ensure_signed_in():
687            return
688
689        wait_for_connectivity(
690            on_connected=lambda: self._auxiliary_window_nav(
691                win_type=InboxWindow,
692                win_create_call=lambda: InboxWindow(
693                    origin_widget=bauiv1.get_special_widget('inbox_button')
694                ),
695            )
696        )
697
698    def _root_ui_store_press(self) -> None:
699        from bauiv1lib.store.browser import StoreBrowserWindow
700
701        if not self._ensure_signed_in_v1():
702            return
703
704        wait_for_connectivity(
705            on_connected=lambda: self._auxiliary_window_nav(
706                win_type=StoreBrowserWindow,
707                win_create_call=lambda: StoreBrowserWindow(
708                    origin_widget=bauiv1.get_special_widget('store_button')
709                ),
710            )
711        )
712
713    def _root_ui_tickets_meter_press(self) -> None:
714        from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
715
716        ResourceTypeInfoWindow(
717            'tickets', origin_widget=bauiv1.get_special_widget('tickets_meter')
718        )
719
720    def _root_ui_tokens_meter_press(self) -> None:
721        from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
722
723        ResourceTypeInfoWindow(
724            'tokens', origin_widget=bauiv1.get_special_widget('tokens_meter')
725        )
726
727    def _root_ui_trophy_meter_press(self) -> None:
728        from bauiv1lib.league.rankwindow import LeagueRankWindow
729
730        if not self._ensure_signed_in_v1():
731            return
732
733        self._auxiliary_window_nav(
734            win_type=LeagueRankWindow,
735            win_create_call=lambda: LeagueRankWindow(
736                origin_widget=bauiv1.get_special_widget('trophy_meter')
737            ),
738        )
739
740    def _root_ui_level_meter_press(self) -> None:
741        from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
742
743        ResourceTypeInfoWindow(
744            'xp', origin_widget=bauiv1.get_special_widget('level_meter')
745        )
746
747    def _root_ui_inventory_press(self) -> None:
748        from bauiv1lib.inventory import InventoryWindow
749
750        if not self._ensure_signed_in_v1():
751            return
752
753        self._auxiliary_window_nav(
754            win_type=InventoryWindow,
755            win_create_call=lambda: InventoryWindow(
756                origin_widget=bauiv1.get_special_widget('inventory_button')
757            ),
758        )
759
760    def _ensure_signed_in(self) -> bool:
761        """Make sure we're signed in (requiring modern v2 accounts)."""
762        plus = bauiv1.app.plus
763        if plus is None:
764            bauiv1.screenmessage('This requires plus.', color=(1, 0, 0))
765            bauiv1.getsound('error').play()
766            return False
767        if plus.accounts.primary is None:
768            show_sign_in_prompt()
769            return False
770        return True
771
772    def _ensure_signed_in_v1(self) -> bool:
773        """Make sure we're signed in (allowing legacy v1-only accounts)."""
774        plus = bauiv1.app.plus
775        if plus is None:
776            bauiv1.screenmessage('This requires plus.', color=(1, 0, 0))
777            bauiv1.getsound('error').play()
778            return False
779        if plus.get_v1_account_state() != 'signed_in':
780            show_sign_in_prompt()
781            return False
782        return True
783
784    def _root_ui_get_tokens_press(self) -> None:
785        from bauiv1lib.gettokens import GetTokensWindow
786
787        if not self._ensure_signed_in():
788            return
789
790        self._auxiliary_window_nav(
791            win_type=GetTokensWindow,
792            win_create_call=lambda: GetTokensWindow(
793                origin_widget=bauiv1.get_special_widget('get_tokens_button')
794            ),
795        )
796
797    def _root_ui_chest_slot_pressed(self, index: int) -> None:
798        from bauiv1lib.chest import (
799            ChestWindow0,
800            ChestWindow1,
801            ChestWindow2,
802            ChestWindow3,
803        )
804
805        widgetid: Literal[
806            'chest_0_button',
807            'chest_1_button',
808            'chest_2_button',
809            'chest_3_button',
810        ]
811        winclass: type[ChestWindow]
812        if index == 0:
813            widgetid = 'chest_0_button'
814            winclass = ChestWindow0
815        elif index == 1:
816            widgetid = 'chest_1_button'
817            winclass = ChestWindow1
818        elif index == 2:
819            widgetid = 'chest_2_button'
820            winclass = ChestWindow2
821        elif index == 3:
822            widgetid = 'chest_3_button'
823            winclass = ChestWindow3
824        else:
825            raise RuntimeError(f'Invalid index {index}')
826
827        wait_for_connectivity(
828            on_connected=lambda: self._auxiliary_window_nav(
829                win_type=winclass,
830                win_create_call=lambda: winclass(
831                    index=index,
832                    origin_widget=bauiv1.get_special_widget(widgetid),
833                ),
834            )
835        )
836
837    def _save_account_display_state(self) -> None:
838
839        # If we currently have an account, save the state of what we're
840        # currently displaying for it in the root ui/etc. We'll then
841        # restore that state as a starting point the next time we are
842        # active. This allows things like league rank changes to be
843        # properly animated even if they occurred while we were offline
844        # or while the UI was hidden.
845
846        if self._current_account_id is not None:
847            vals = _baclassic.get_account_display_state()
848            if vals is not None:
849                # Stuff our account id in there and save it to our
850                # config.
851                assert 'a' not in vals
852                vals['a'] = self._current_account_id
853                cfg = babase.app.config
854                cfg[self._LEAGUE_VIS_VALS_CONFIG_KEY] = vals
855                cfg.commit()
856
857    def _restore_account_display_state(self) -> None:
858
859        # If we currently have an account and it matches the
860        # display-state we have stored in the config, restore the state.
861        if self._current_account_id is not None:
862            cfg = babase.app.config
863            vals = cfg.get(self._LEAGUE_VIS_VALS_CONFIG_KEY)
864            if isinstance(vals, dict):
865                valsaccount = vals.get('a')
866                if (
867                    isinstance(valsaccount, str)
868                    and valsaccount == self._current_account_id
869                ):
870                    _baclassic.set_account_display_state(vals)

AppMode for the classic BombSquad experience.

@override
@classmethod
def get_app_experience(cls) -> bacommon.app.AppExperience:
53    @override
54    @classmethod
55    def get_app_experience(cls) -> AppExperience:
56        return AppExperience.MELEE

Return the overall experience provided by this mode.

@override
def handle_intent(self, intent: babase.AppIntent) -> None:
66    @override
67    def handle_intent(self, intent: babase.AppIntent) -> None:
68        if isinstance(intent, babase.AppIntentExec):
69            _baclassic.classic_app_mode_handle_app_intent_exec(intent.code)
70            return
71        assert isinstance(intent, babase.AppIntentDefault)
72        _baclassic.classic_app_mode_handle_app_intent_default()

Handle an intent.

@override
def on_activate(self) -> None:
 74    @override
 75    def on_activate(self) -> None:
 76
 77        # Let the native layer do its thing.
 78        _baclassic.classic_app_mode_activate()
 79
 80        app = babase.app
 81        plus = app.plus
 82        assert plus is not None
 83
 84        # Wire up the root ui to do what we want.
 85        ui = app.ui_v1
 86        ui.root_ui_calls[ui.RootUIElement.ACCOUNT_BUTTON] = (
 87            self._root_ui_account_press
 88        )
 89        ui.root_ui_calls[ui.RootUIElement.MENU_BUTTON] = (
 90            self._root_ui_menu_press
 91        )
 92        ui.root_ui_calls[ui.RootUIElement.SQUAD_BUTTON] = (
 93            self._root_ui_squad_press
 94        )
 95        ui.root_ui_calls[ui.RootUIElement.SETTINGS_BUTTON] = (
 96            self._root_ui_settings_press
 97        )
 98        ui.root_ui_calls[ui.RootUIElement.STORE_BUTTON] = (
 99            self._root_ui_store_press
100        )
101        ui.root_ui_calls[ui.RootUIElement.INVENTORY_BUTTON] = (
102            self._root_ui_inventory_press
103        )
104        ui.root_ui_calls[ui.RootUIElement.GET_TOKENS_BUTTON] = (
105            self._root_ui_get_tokens_press
106        )
107        ui.root_ui_calls[ui.RootUIElement.INBOX_BUTTON] = (
108            self._root_ui_inbox_press
109        )
110        ui.root_ui_calls[ui.RootUIElement.TICKETS_METER] = (
111            self._root_ui_tickets_meter_press
112        )
113        ui.root_ui_calls[ui.RootUIElement.TOKENS_METER] = (
114            self._root_ui_tokens_meter_press
115        )
116        ui.root_ui_calls[ui.RootUIElement.TROPHY_METER] = (
117            self._root_ui_trophy_meter_press
118        )
119        ui.root_ui_calls[ui.RootUIElement.LEVEL_METER] = (
120            self._root_ui_level_meter_press
121        )
122        ui.root_ui_calls[ui.RootUIElement.ACHIEVEMENTS_BUTTON] = (
123            self._root_ui_achievements_press
124        )
125        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_0] = partial(
126            self._root_ui_chest_slot_pressed, 0
127        )
128        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_1] = partial(
129            self._root_ui_chest_slot_pressed, 1
130        )
131        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_2] = partial(
132            self._root_ui_chest_slot_pressed, 2
133        )
134        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_3] = partial(
135            self._root_ui_chest_slot_pressed, 3
136        )
137
138        # We want to be informed when connectivity changes.
139        self._on_connectivity_changed_callback = (
140            plus.cloud.on_connectivity_changed_callbacks.register(
141                self._update_for_connectivity_change
142            )
143        )
144        # We want to be informed when primary account changes.
145        self._on_primary_account_changed_callback = (
146            plus.accounts.on_primary_account_changed_callbacks.register(
147                self._update_for_primary_account
148            )
149        )
150        # Establish subscriptions/etc. for any current primary account.
151        self._update_for_primary_account(plus.accounts.primary)
152        self._have_connectivity = plus.cloud.is_connected()
153        self._update_for_connectivity_change(self._have_connectivity)

Called when the mode is becoming the active one fro the app.

@override
def on_deactivate(self) -> None:
155    @override
156    def on_deactivate(self) -> None:
157
158        classic = babase.app.classic
159
160        # Store latest league vis vals for any active account.
161        self._save_account_display_state()
162
163        # Stop being informed of account changes.
164        self._on_primary_account_changed_callback = None
165
166        # Cancel any ui-pause we may have had going.
167        self._purchase_ui_pause = None
168
169        # Remove anything following any current account.
170        self._update_for_primary_account(None)
171
172        # Save where we were in the UI so we return there next time.
173        if classic is not None:
174            classic.save_ui_state()
175
176        # Let the native layer do its thing.
177        _baclassic.classic_app_mode_deactivate()

Called when the mode stops being the active one for the app.

On platforms where the app is explicitly exited (such as desktop PC) this will also be called at app shutdown.

To best cover both mobile and desktop style platforms, actions such as saving state should generally happen in response to both on_deactivate() and on_app_active_changed() (when active is False).

@override
def on_app_active_changed(self) -> None:
179    @override
180    def on_app_active_changed(self) -> None:
181        if not babase.app.active:
182            # If we've gone inactive, bring up the main menu, which has the
183            # side effect of pausing the action (when possible).
184            babase.invoke_main_menu()
185
186            # Also store any league vis state for the active account.
187            # this may be our last chance to do this on mobile.
188            self._save_account_display_state()

Called when app active state changes while in this app-mode.

This corresponds to babase.App.active. App-active state becomes false when the app is hidden, minimized, backgrounded, etc. The app-mode may want to take action such as pausing a running game or saving state when this occurs.

On platforms such as mobile where apps get suspended and later silently terminated by the OS, this is likely to be the last reliable place to save state/etc.

To best cover both mobile and desktop style platforms, actions such as saving state should generally happen in response to both on_deactivate() and on_app_active_changed() (when active is False).

@override
def on_purchase_process_begin(self, item_id: str, user_initiated: bool) -> None:
190    @override
191    def on_purchase_process_begin(
192        self, item_id: str, user_initiated: bool
193    ) -> None:
194
195        # Do the default thing (announces 'updating account...')
196        super().on_purchase_process_begin(
197            item_id=item_id, user_initiated=user_initiated
198        )
199
200        # Pause the root ui so stuff like token counts don't change
201        # automatically, allowing us to animate them. Note that we
202        # need to explicitly kill this pause if we are deactivated since
203        # we wouldn't get the on_purchase_process_end() call; the next
204        # app-mode would.
205        self._purchase_ui_pause = bauiv1.RootUIUpdatePause()
206
207        # Also grab our last known token count here to plug into animations.
208        # We need to do this here before the purchase gets submitted so that
209        # we know we're seeing the old value.
210        assert babase.app.classic is not None
211        self._last_tokens_value = babase.app.classic.tokens

Called when in-app-purchase processing is beginning.

This call happens after a purchase has been completed locally but before its receipt/info is sent to the master-server to apply to the account.

@override
def on_purchase_process_end(self, item_id: str, user_initiated: bool, applied: bool) -> None:
213    @override
214    def on_purchase_process_end(
215        self, item_id: str, user_initiated: bool, applied: bool
216    ) -> None:
217
218        # Let the UI auto-update again after any animations we may apply
219        # here.
220        self._purchase_ui_pause = None
221
222        # Ignore user_initiated; we want to announce newly applied stuff
223        # even if it was from a different launch or client or whatever.
224        del user_initiated
225
226        # If the purchase wasn't applied, do nothing. This likely means it
227        # was redundant or something else harmless.
228        if not applied:
229            return
230
231        if item_id.startswith('tokens'):
232            if item_id == 'tokens1':
233                tokens = bacommon.bs.TOKENS1_COUNT
234                tokens_str = str(tokens)
235                anim_time = 2.0
236            elif item_id == 'tokens2':
237                tokens = bacommon.bs.TOKENS2_COUNT
238                tokens_str = str(tokens)
239                anim_time = 2.5
240            elif item_id == 'tokens3':
241                tokens = bacommon.bs.TOKENS3_COUNT
242                tokens_str = str(tokens)
243                anim_time = 3.0
244            elif item_id == 'tokens4':
245                tokens = bacommon.bs.TOKENS4_COUNT
246                tokens_str = str(tokens)
247                anim_time = 3.5
248            else:
249                tokens = 0
250                tokens_str = '???'
251                anim_time = 2.5
252                logging.warning(
253                    'Unhandled item_id in on_purchase_process_end: %s', item_id
254                )
255
256            assert babase.app.classic is not None
257            effects: list[bacommon.bs.ClientEffect] = [
258                bacommon.bs.ClientEffectTokensAnimation(
259                    duration=anim_time,
260                    startvalue=self._last_tokens_value,
261                    endvalue=self._last_tokens_value + tokens,
262                ),
263                bacommon.bs.ClientEffectDelay(anim_time),
264                bacommon.bs.ClientEffectScreenMessage(
265                    message='You got ${COUNT} tokens!',
266                    subs=['${COUNT}', tokens_str],
267                    color=(0, 1, 0),
268                ),
269                bacommon.bs.ClientEffectSound(
270                    sound=bacommon.bs.ClientEffectSound.Sound.CASH_REGISTER
271                ),
272            ]
273            babase.app.classic.run_bs_client_effects(effects)
274
275        elif item_id.startswith('gold_pass'):
276            babase.screenmessage(
277                babase.Lstr(
278                    translate=('serverResponses', 'You got a ${ITEM}!'),
279                    subs=[
280                        (
281                            '${ITEM}',
282                            babase.Lstr(resource='goldPass.goldPassText'),
283                        )
284                    ],
285                ),
286                color=(0, 1, 0),
287            )
288            if babase.asset_loads_allowed():
289                babase.getsimplesound('cashRegister').play()
290
291        else:
292
293            # Fallback: simply announce item id.
294            logging.warning(
295                'on_purchase_process_end got unexpected item_id: %s.', item_id
296            )
297            babase.screenmessage(
298                babase.Lstr(
299                    translate=('serverResponses', 'You got a ${ITEM}!'),
300                    subs=[('${ITEM}', item_id)],
301                ),
302                color=(0, 1, 0),
303            )
304            if babase.asset_loads_allowed():
305                babase.getsimplesound('cashRegister').play()

Called when in-app-purchase processing completes.

Each call to on_purchase_process_begin will be followed up by a call to this method. If the purchase was found to be valid and was applied to the account, applied will be True. In the case of redundant or invalid purchases or communication failures it will be False.

def on_engine_will_reset(self) -> None:
307    def on_engine_will_reset(self) -> None:
308        """Called just before classic resets the engine.
309
310        This happens at various times such as session switches.
311        """
312
313        self._save_account_display_state()

Called just before classic resets the engine.

This happens at various times such as session switches.

def on_engine_did_reset(self) -> None:
315    def on_engine_did_reset(self) -> None:
316        """Called just after classic resets the engine.
317
318        This happens at various times such as session switches.
319        """
320
321        # Restore any old league vis state we had; this allows the user
322        # to see animations for league improvements or other changes
323        # that have occurred since the last time we were visible.
324        self._restore_account_display_state()

Called just after classic resets the engine.

This happens at various times such as session switches.

class ClassicAppSubsystem(babase._appsubsystem.AppSubsystem):
 38class ClassicAppSubsystem(babase.AppSubsystem):
 39    """Subsystem for classic functionality in the app.
 40
 41    The single shared instance of this app can be accessed at
 42    babase.app.classic. Note that it is possible for babase.app.classic to
 43    be None if the classic package is not present, and code should handle
 44    that case gracefully.
 45    """
 46
 47    # pylint: disable=too-many-public-methods
 48
 49    from baclassic._music import MusicPlayMode
 50
 51    def __init__(self) -> None:
 52        super().__init__()
 53        self._env = babase.env()
 54
 55        self.accounts = AccountV1Subsystem()
 56        self.ads = AdsSubsystem()
 57        self.ach = AchievementSubsystem()
 58        self.store = StoreSubsystem()
 59        self.music = MusicSubsystem()
 60
 61        # Co-op Campaigns.
 62        self.campaigns: dict[str, bascenev1.Campaign] = {}
 63        self.custom_coop_practice_games: list[str] = []
 64
 65        # Lobby.
 66        self.lobby_random_profile_index: int = 1
 67        self.lobby_random_char_index_offset = random.randrange(1000)
 68        self.lobby_account_profile_device_id: int | None = None
 69
 70        # Misc.
 71        self.tips: list[str] = []
 72        self.stress_test_update_timer: babase.AppTimer | None = None
 73        self.stress_test_update_timer_2: babase.AppTimer | None = None
 74        self.value_test_defaults: dict = {}
 75        self.ping_thread_count = 0
 76        self.allow_ticket_purchases: bool = True
 77
 78        # Classic-specific account state.
 79        self.remove_ads = False
 80        self.gold_pass = False
 81        self.tokens = 0
 82        self.chest_dock_full = False
 83
 84        # Main Menu.
 85        self.main_menu_did_initial_transition = False
 86        self.main_menu_last_news_fetch_time: float | None = None
 87
 88        # Spaz.
 89        self.spaz_appearances: dict[str, spazappearance.Appearance] = {}
 90        self.last_spaz_turbo_warn_time = babase.AppTime(-99999.0)
 91
 92        # Server Mode.
 93        self.server: ServerController | None = None
 94
 95        self.log_have_new = False
 96        self.log_upload_timer_started = False
 97        self.printed_live_object_warning = False
 98
 99        # We include this extra hash with shared input-mapping names so
100        # that we don't share mappings between differently-configured
101        # systems. For instance, different android devices may give
102        # different key values for the same controller type so we keep
103        # their mappings distinct.
104        self.input_map_hash: str | None = None
105
106        # Maps.
107        self.maps: dict[str, type[bascenev1.Map]] = {}
108
109        # Gameplay.
110        self.teams_series_length = 7  # Deprecated, left for old mods.
111        self.ffa_series_length = 24  # Deprecated, left for old mods.
112        self.coop_session_args: dict = {}
113
114        # UI.
115        self.first_main_menu = True  # FIXME: Move to mainmenu class.
116        self.did_menu_intro = False  # FIXME: Move to mainmenu class.
117        self.main_menu_window_refresh_check_count = 0  # FIXME: Mv to mainmenu.
118        self.invite_confirm_windows: list[Any] = []  # FIXME: Don't use Any.
119        self.party_window: weakref.ref[PartyWindow] | None = None
120        self.main_menu_resume_callbacks: list = []
121        self.saved_ui_state: bauiv1.MainWindowState | None = None
122
123        # Store.
124        self.store_layout: dict[str, list[dict[str, Any]]] | None = None
125        self.store_items: dict[str, dict] | None = None
126        self.pro_sale_start_time: int | None = None
127        self.pro_sale_start_val: int | None = None
128
129    def add_main_menu_close_callback(self, call: Callable[[], Any]) -> None:
130        """(internal)"""
131
132        # If there's no main window up, just call immediately.
133        if not babase.app.ui_v1.has_main_window():
134            with babase.ContextRef.empty():
135                call()
136        else:
137            self.main_menu_resume_callbacks.append(call)
138
139    @property
140    def platform(self) -> str:
141        """Name of the current platform.
142
143        Examples are: 'mac', 'windows', android'.
144        """
145        assert isinstance(self._env['platform'], str)
146        return self._env['platform']
147
148    def scene_v1_protocol_version(self) -> int:
149        """(internal)"""
150        return bascenev1.protocol_version()
151
152    @property
153    def subplatform(self) -> str:
154        """String for subplatform.
155
156        Can be empty. For the 'android' platform, subplatform may
157        be 'google', 'amazon', etc.
158        """
159        assert isinstance(self._env['subplatform'], str)
160        return self._env['subplatform']
161
162    @property
163    def legacy_user_agent_string(self) -> str:
164        """String containing various bits of info about OS/device/etc."""
165        assert isinstance(self._env['legacy_user_agent_string'], str)
166        return self._env['legacy_user_agent_string']
167
168    @override
169    def on_app_loading(self) -> None:
170        from bascenev1lib.actor import spazappearance
171        from bascenev1lib import maps as stdmaps
172
173        plus = babase.app.plus
174        assert plus is not None
175
176        env = babase.app.env
177        cfg = babase.app.config
178
179        self.music.on_app_loading()
180
181        # Non-test, non-debug builds should generally be blessed; warn
182        # if not (so I don't accidentally release a build that can't
183        # play tourneys).
184        if not env.debug and not env.test and not plus.is_blessed():
185            babase.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0))
186
187        stdmaps.register_all_maps()
188
189        spazappearance.register_appearances()
190        bascenev1.init_campaigns()
191
192        launch_count = cfg.get('launchCount', 0)
193        launch_count += 1
194
195        # So we know how many times we've run the game at various
196        # version milestones.
197        for key in ('lc14173', 'lc14292'):
198            cfg.setdefault(key, launch_count)
199
200        cfg['launchCount'] = launch_count
201        cfg.commit()
202
203        # If there's a leftover log file, attempt to upload it to the
204        # master-server and/or get rid of it.
205        babase.handle_leftover_v1_cloud_log_file()
206
207        self.accounts.on_app_loading()
208
209    @override
210    def on_app_suspend(self) -> None:
211        self.accounts.on_app_suspend()
212
213    @override
214    def on_app_unsuspend(self) -> None:
215        self.accounts.on_app_unsuspend()
216        self.music.on_app_unsuspend()
217
218    @override
219    def on_app_shutdown(self) -> None:
220        self.music.on_app_shutdown()
221
222    def pause(self) -> None:
223        """Pause the game due to a user request or menu popping up.
224
225        If there's a foreground host-activity that says it's pausable, tell it
226        to pause. Note: we now no longer pause if there are connected clients.
227        """
228        activity: bascenev1.Activity | None = (
229            bascenev1.get_foreground_host_activity()
230        )
231        if (
232            activity is not None
233            and activity.allow_pausing
234            and not bascenev1.have_connected_clients()
235        ):
236            from babase import Lstr
237            from bascenev1 import NodeActor
238
239            # FIXME: Shouldn't be touching scene stuff here; should just
240            #  pass the request on to the host-session.
241            with activity.context:
242                globs = activity.globalsnode
243                if not globs.paused:
244                    bascenev1.getsound('refWhistle').play()
245                    globs.paused = True
246
247                # FIXME: This should not be an attr on Actor.
248                activity.paused_text = NodeActor(
249                    bascenev1.newnode(
250                        'text',
251                        attrs={
252                            'text': Lstr(resource='pausedByHostText'),
253                            'client_only': True,
254                            'flatness': 1.0,
255                            'h_align': 'center',
256                        },
257                    )
258                )
259
260    def resume(self) -> None:
261        """Resume the game due to a user request or menu closing.
262
263        If there's a foreground host-activity that's currently paused, tell it
264        to resume.
265        """
266
267        # FIXME: Shouldn't be touching scene stuff here; should just
268        #  pass the request on to the host-session.
269        activity = bascenev1.get_foreground_host_activity()
270        if activity is not None:
271            with activity.context:
272                globs = activity.globalsnode
273                if globs.paused:
274                    bascenev1.getsound('refWhistle').play()
275                    globs.paused = False
276
277                    # FIXME: This should not be an actor attr.
278                    activity.paused_text = None
279
280    def add_coop_practice_level(self, level: bascenev1.Level) -> None:
281        """Adds an individual level to the 'practice' section in Co-op."""
282
283        # Assign this level to our catch-all campaign.
284        self.campaigns['Challenges'].addlevel(level)
285
286        # Make note to add it to our challenges UI.
287        self.custom_coop_practice_games.append(f'Challenges:{level.name}')
288
289    def launch_coop_game(
290        self, game: str, force: bool = False, args: dict | None = None
291    ) -> bool:
292        """High level way to launch a local co-op session."""
293        # pylint: disable=cyclic-import
294        from bauiv1lib.coop.level import CoopLevelLockedWindow
295
296        assert babase.app.classic is not None
297
298        if args is None:
299            args = {}
300        if game == '':
301            raise ValueError('empty game name')
302        campaignname, levelname = game.split(':')
303        campaign = babase.app.classic.getcampaign(campaignname)
304
305        # If this campaign is sequential, make sure we've completed the
306        # one before this.
307        if campaign.sequential and not force:
308            for level in campaign.levels:
309                if level.name == levelname:
310                    break
311                if not level.complete:
312                    CoopLevelLockedWindow(
313                        campaign.getlevel(levelname).displayname,
314                        campaign.getlevel(level.name).displayname,
315                    )
316                    return False
317
318        # Save where we are in the UI to come back to when done.
319        babase.app.classic.save_ui_state()
320
321        # Ok, we're good to go.
322        self.coop_session_args = {
323            'campaign': campaignname,
324            'level': levelname,
325        }
326        for arg_name, arg_val in list(args.items()):
327            self.coop_session_args[arg_name] = arg_val
328
329        def _fade_end() -> None:
330            from bascenev1 import CoopSession
331
332            try:
333                bascenev1.new_host_session(CoopSession)
334            except Exception:
335                logging.exception('Error creating coopsession after fade end.')
336                from bascenev1lib.mainmenu import MainMenuSession
337
338                bascenev1.new_host_session(MainMenuSession)
339
340        babase.fade_screen(False, endcall=_fade_end)
341        return True
342
343    def return_to_main_menu_session_gracefully(
344        self, reset_ui: bool = True
345    ) -> None:
346        """Attempt to cleanly get back to the main menu."""
347        # pylint: disable=cyclic-import
348        from baclassic import _benchmark
349        from bascenev1lib.mainmenu import MainMenuSession
350
351        plus = babase.app.plus
352        assert plus is not None
353
354        if reset_ui:
355            babase.app.ui_v1.clear_main_window()
356
357        if isinstance(bascenev1.get_foreground_host_session(), MainMenuSession):
358            # It may be possible we're on the main menu but the screen
359            # is faded so fade back in.
360            babase.fade_screen(True)
361            return
362
363        _benchmark.stop_stress_test()  # Stop stress-test if in progress.
364
365        # If we're in a host-session, tell them to end. This lets them
366        # tear themselves down gracefully.
367        host_session: bascenev1.Session | None = (
368            bascenev1.get_foreground_host_session()
369        )
370        if host_session is not None:
371            # Kick off a little transaction so we'll hopefully have all
372            # the latest account state when we get back to the menu.
373            plus.add_v1_account_transaction(
374                {'type': 'END_SESSION', 'sType': str(type(host_session))}
375            )
376            plus.run_v1_account_transactions()
377
378            host_session.end()
379
380        # Otherwise just force the issue.
381        else:
382            babase.pushcall(
383                babase.Call(bascenev1.new_host_session, MainMenuSession)
384            )
385
386    def getmaps(self, playtype: str) -> list[str]:
387        """Return a list of bascenev1.Map types supporting a playtype str.
388
389        Maps supporting a given playtype must provide a particular set of
390        features and lend themselves to a certain style of play.
391
392        Play Types:
393
394        'melee'
395          General fighting map.
396          Has one or more 'spawn' locations.
397
398        'team_flag'
399          For games such as Capture The Flag where each team spawns by a flag.
400          Has two or more 'spawn' locations, each with a corresponding 'flag'
401          location (based on index).
402
403        'single_flag'
404          For games such as King of the Hill or Keep Away where multiple teams
405          are fighting over a single flag.
406          Has two or more 'spawn' locations and 1 'flag_default' location.
407
408        'conquest'
409          For games such as Conquest where flags are spread throughout the map
410          - has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations.
411
412        'king_of_the_hill' - has 2+ 'spawn' locations,
413           1+ 'flag_default' locations, and 1+ 'powerup_spawn' locations
414
415        'hockey'
416          For hockey games.
417          Has two 'goal' locations, corresponding 'spawn' locations, and one
418          'flag_default' location (for where puck spawns)
419
420        'football'
421          For football games.
422          Has two 'goal' locations, corresponding 'spawn' locations, and one
423          'flag_default' location (for where flag/ball/etc. spawns)
424
425        'race'
426          For racing games where players much touch each region in order.
427          Has two or more 'race_point' locations.
428        """
429        return sorted(
430            key
431            for key, val in self.maps.items()
432            if playtype in val.get_play_types()
433        )
434
435    def game_begin_analytics(self) -> None:
436        """(internal)"""
437        from baclassic import _analytics
438
439        _analytics.game_begin_analytics()
440
441    @classmethod
442    def json_prep(cls, data: Any) -> Any:
443        """Return a json-friendly version of the provided data.
444
445        This converts any tuples to lists and any bytes to strings
446        (interpreted as utf-8, ignoring errors). Logs errors (just once)
447        if any data is modified/discarded/unsupported.
448        """
449
450        if isinstance(data, dict):
451            return dict(
452                (cls.json_prep(key), cls.json_prep(value))
453                for key, value in list(data.items())
454            )
455        if isinstance(data, list):
456            return [cls.json_prep(element) for element in data]
457        if isinstance(data, tuple):
458            logging.exception('json_prep encountered tuple')
459            return [cls.json_prep(element) for element in data]
460        if isinstance(data, bytes):
461            try:
462                return data.decode(errors='ignore')
463            except Exception:
464                logging.exception('json_prep encountered utf-8 decode error')
465                return data.decode(errors='ignore')
466        if not isinstance(data, (str, float, bool, type(None), int)):
467            logging.exception(
468                'got unsupported type in json_prep: %s', type(data)
469            )
470        return data
471
472    def master_server_v1_get(
473        self,
474        request: str,
475        data: dict[str, Any],
476        callback: MasterServerCallback | None = None,
477        response_type: MasterServerResponseType = MasterServerResponseType.JSON,
478    ) -> None:
479        """Make a call to the master server via a http GET."""
480
481        MasterServerV1CallThread(
482            request, 'get', data, callback, response_type
483        ).start()
484
485    def master_server_v1_post(
486        self,
487        request: str,
488        data: dict[str, Any],
489        callback: MasterServerCallback | None = None,
490        response_type: MasterServerResponseType = MasterServerResponseType.JSON,
491    ) -> None:
492        """Make a call to the master server via a http POST."""
493        MasterServerV1CallThread(
494            request, 'post', data, callback, response_type
495        ).start()
496
497    def set_tournament_prize_image(
498        self, entry: dict[str, Any], index: int, image: bauiv1.Widget
499    ) -> None:
500        """Given a tournament entry, return strings for its prize levels."""
501        from baclassic import _tournament
502
503        return _tournament.set_tournament_prize_chest_image(entry, index, image)
504
505    def create_in_game_tournament_prize_image(
506        self,
507        entry: dict[str, Any],
508        index: int,
509        position: tuple[float, float],
510    ) -> None:
511        """Given a tournament entry, return strings for its prize levels."""
512        from baclassic import _tournament
513
514        _tournament.create_in_game_tournament_prize_image(
515            entry, index, position
516        )
517
518    def get_tournament_prize_strings(
519        self, entry: dict[str, Any], include_tickets: bool
520    ) -> list[str]:
521        """Given a tournament entry, return strings for its prize levels."""
522        from baclassic import _tournament
523
524        return _tournament.get_tournament_prize_strings(
525            entry, include_tickets=include_tickets
526        )
527
528    def getcampaign(self, name: str) -> bascenev1.Campaign:
529        """Return a campaign by name."""
530        return self.campaigns[name]
531
532    def get_next_tip(self) -> str:
533        """Returns the next tip to be displayed."""
534        if not self.tips:
535            for tip in get_all_tips():
536                self.tips.insert(random.randint(0, len(self.tips)), tip)
537        tip = self.tips.pop()
538        return tip
539
540    def run_cpu_benchmark(self) -> None:
541        """Kick off a benchmark to test cpu speeds."""
542        from baclassic._benchmark import run_cpu_benchmark
543
544        run_cpu_benchmark()
545
546    def run_media_reload_benchmark(self) -> None:
547        """Kick off a benchmark to test media reloading speeds."""
548        from baclassic._benchmark import run_media_reload_benchmark
549
550        run_media_reload_benchmark()
551
552    def run_stress_test(
553        self,
554        *,
555        playlist_type: str = 'Random',
556        playlist_name: str = '__default__',
557        player_count: int = 8,
558        round_duration: int = 30,
559        attract_mode: bool = False,
560    ) -> None:
561        """Run a stress test."""
562        from baclassic._benchmark import run_stress_test
563
564        run_stress_test(
565            playlist_type=playlist_type,
566            playlist_name=playlist_name,
567            player_count=player_count,
568            round_duration=round_duration,
569            attract_mode=attract_mode,
570        )
571
572    def get_input_device_mapped_value(
573        self,
574        device: bascenev1.InputDevice,
575        name: str,
576        default: bool = False,
577    ) -> Any:
578        """Return a mapped value for an input device.
579
580        This checks the user config and falls back to default values
581        where available.
582        """
583        return _input.get_input_device_mapped_value(
584            device.name, device.unique_identifier, name, default
585        )
586
587    def get_input_device_map_hash(
588        self, inputdevice: bascenev1.InputDevice
589    ) -> str:
590        """Given an input device, return hash based on its raw input values."""
591        del inputdevice  # unused currently
592        return _input.get_input_device_map_hash()
593
594    def get_input_device_config(
595        self, inputdevice: bascenev1.InputDevice, default: bool
596    ) -> tuple[dict, str]:
597        """Given an input device, return its config dict in the app config.
598
599        The dict will be created if it does not exist.
600        """
601        return _input.get_input_device_config(
602            inputdevice.name, inputdevice.unique_identifier, default
603        )
604
605    def get_player_colors(self) -> list[tuple[float, float, float]]:
606        """Return user-selectable player colors."""
607        return bascenev1.get_player_colors()
608
609    def get_player_profile_icon(self, profilename: str) -> str:
610        """Given a profile name, returns an icon string for it.
611
612        (non-account profiles only)
613        """
614        return bascenev1.get_player_profile_icon(profilename)
615
616    def get_player_profile_colors(
617        self,
618        profilename: str | None,
619        profiles: dict[str, dict[str, Any]] | None = None,
620    ) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
621        """Given a profile, return colors for them."""
622        return bascenev1.get_player_profile_colors(profilename, profiles)
623
624    def get_foreground_host_session(self) -> bascenev1.Session | None:
625        """(internal)"""
626        return bascenev1.get_foreground_host_session()
627
628    def get_foreground_host_activity(self) -> bascenev1.Activity | None:
629        """(internal)"""
630        return bascenev1.get_foreground_host_activity()
631
632    def value_test(
633        self,
634        arg: str,
635        change: float | None = None,
636        absolute: float | None = None,
637    ) -> float:
638        """(internal)"""
639        return _baclassic.value_test(arg, change, absolute)
640
641    def set_master_server_source(self, source: int) -> None:
642        """(internal)"""
643        bascenev1.set_master_server_source(source)
644
645    def get_game_port(self) -> int:
646        """(internal)"""
647        return bascenev1.get_game_port()
648
649    def v2_upgrade_window(self, login_name: str, code: str) -> None:
650        """(internal)"""
651
652        from bauiv1lib.v2upgrade import V2UpgradeWindow
653
654        V2UpgradeWindow(login_name, code)
655
656    def account_link_code_window(self, data: dict[str, Any]) -> None:
657        """(internal)"""
658        from bauiv1lib.account.link import AccountLinkCodeWindow
659
660        AccountLinkCodeWindow(data)
661
662    def server_dialog(self, delay: float, data: dict[str, Any]) -> None:
663        """(internal)"""
664        from bauiv1lib.serverdialog import (
665            ServerDialogData,
666            ServerDialogWindow,
667        )
668
669        try:
670            sddata = dataclass_from_dict(ServerDialogData, data)
671        except Exception:
672            sddata = None
673            logging.warning(
674                'Got malformatted ServerDialogData: %s',
675                data,
676            )
677        if sddata is not None:
678            babase.apptimer(
679                delay,
680                babase.Call(ServerDialogWindow, sddata),
681            )
682
683    def show_url_window(self, address: str) -> None:
684        """(internal)"""
685        from bauiv1lib.url import ShowURLWindow
686
687        ShowURLWindow(address)
688
689    def quit_window(self, quit_type: babase.QuitType) -> None:
690        """(internal)"""
691        from bauiv1lib.confirm import QuitWindow
692
693        QuitWindow(quit_type)
694
695    def tournament_entry_window(
696        self,
697        tournament_id: str,
698        *,
699        tournament_activity: bascenev1.Activity | None = None,
700        position: tuple[float, float] = (0.0, 0.0),
701        delegate: Any = None,
702        scale: float | None = None,
703        offset: tuple[float, float] = (0.0, 0.0),
704        on_close_call: Callable[[], Any] | None = None,
705    ) -> None:
706        """(internal)"""
707        from bauiv1lib.tournamententry import TournamentEntryWindow
708
709        TournamentEntryWindow(
710            tournament_id,
711            tournament_activity,
712            position,
713            delegate,
714            scale,
715            offset,
716            on_close_call,
717        )
718
719    def get_main_menu_session(self) -> type[bascenev1.Session]:
720        """(internal)"""
721        from bascenev1lib.mainmenu import MainMenuSession
722
723        return MainMenuSession
724
725    def profile_browser_window(
726        self,
727        transition: str = 'in_right',
728        origin_widget: bauiv1.Widget | None = None,
729        selected_profile: str | None = None,
730    ) -> None:
731        """(internal)"""
732        from bauiv1lib.profile.browser import ProfileBrowserWindow
733
734        main_window = babase.app.ui_v1.get_main_window()
735        if main_window is not None:
736            logging.warning(
737                'profile_browser_window()'
738                ' called with existing main window; should not happen.'
739            )
740            return
741
742        babase.app.ui_v1.set_main_window(
743            ProfileBrowserWindow(
744                transition=transition,
745                selected_profile=selected_profile,
746                origin_widget=origin_widget,
747                minimal_toolbar=True,
748            ),
749            is_top_level=True,
750            suppress_warning=True,
751        )
752
753    def preload_map_preview_media(self) -> None:
754        """Preload media needed for map preview UIs."""
755        try:
756            bauiv1.getmesh('level_select_button_opaque')
757            bauiv1.getmesh('level_select_button_transparent')
758            for maptype in list(self.maps.values()):
759                map_tex_name = maptype.get_preview_texture_name()
760                if map_tex_name is not None:
761                    bauiv1.gettexture(map_tex_name)
762        except Exception:
763            logging.exception('Error preloading map preview media.')
764
765    def party_icon_activate(self, origin: Sequence[float]) -> None:
766        """(internal)"""
767        from bauiv1lib.party import PartyWindow
768        from babase import app
769
770        assert app.env.gui
771
772        # Play explicit swish sound so it occurs due to keypresses/etc.
773        # This means we have to disable it for any button or else we get
774        # double.
775        bauiv1.getsound('swish').play()
776
777        # If it exists, dismiss it; otherwise make a new one.
778        party_window = (
779            None if self.party_window is None else self.party_window()
780        )
781        if party_window is not None:
782            party_window.close()
783        else:
784            self.party_window = weakref.ref(PartyWindow(origin=origin))
785
786    def device_menu_press(self, device_id: int | None) -> None:
787        """(internal)"""
788        from bauiv1lib.ingamemenu import InGameMenuWindow
789        from bauiv1 import set_ui_input_device
790
791        assert babase.app is not None
792        in_main_menu = babase.app.ui_v1.has_main_window()
793        if not in_main_menu:
794            set_ui_input_device(device_id)
795
796            # Hack(ish). We play swish sound here so it happens for
797            # device presses, but this means we need to disable default
798            # swish sounds for any menu buttons or we'll get double.
799            if babase.app.env.gui:
800                bauiv1.getsound('swish').play()
801
802            # Pause gameplay.
803            self.pause()
804
805            babase.app.ui_v1.set_main_window(
806                InGameMenuWindow(), is_top_level=True, suppress_warning=True
807            )
808
809    def save_ui_state(self) -> None:
810        """Store our current place in the UI."""
811        ui = babase.app.ui_v1
812        mainwindow = ui.get_main_window()
813        if mainwindow is not None:
814            self.saved_ui_state = ui.save_main_window_state(mainwindow)
815        else:
816            self.saved_ui_state = None
817
818    def invoke_main_menu_ui(self) -> None:
819        """Bring up main menu ui."""
820
821        # Bring up the last place we were, or start at the main menu
822        # otherwise.
823        app = bauiv1.app
824        env = app.env
825        with bascenev1.ContextRef.empty():
826
827            assert app.classic is not None
828            if app.env.headless:
829                # UI stuff fails now in headless builds; avoid it.
830                pass
831            else:
832
833                # When coming back from a kiosk-mode game, jump to the
834                # kiosk start screen.
835                if env.demo or env.arcade:
836                    # pylint: disable=cyclic-import
837                    from bauiv1lib.kiosk import KioskWindow
838
839                    app.ui_v1.set_main_window(
840                        KioskWindow(), is_top_level=True, suppress_warning=True
841                    )
842                else:
843                    # If there's a saved ui state, restore that.
844                    if self.saved_ui_state is not None:
845                        app.ui_v1.restore_main_window_state(self.saved_ui_state)
846                    else:
847                        # Otherwise start fresh at the main menu.
848                        from bauiv1lib.mainmenu import MainMenuWindow
849
850                        app.ui_v1.set_main_window(
851                            MainMenuWindow(transition=None),
852                            is_top_level=True,
853                            suppress_warning=True,
854                        )
855
856    @staticmethod
857    def run_bs_client_effects(
858        effects: list[bacommon.bs.ClientEffect], delay: float = 0.0
859    ) -> None:
860        """Run client effects sent from the master server."""
861        from baclassic._clienteffect import run_bs_client_effects
862
863        run_bs_client_effects(effects, delay=delay)
864
865    @staticmethod
866    def basic_client_ui_button_label_str(
867        label: bacommon.bs.BasicClientUI.ButtonLabel,
868    ) -> babase.Lstr:
869        """Given a client-ui label, return an Lstr."""
870        import bacommon.bs
871
872        cls = bacommon.bs.BasicClientUI.ButtonLabel
873        if label is cls.UNKNOWN:
874            # Server should not be sending us unknown stuff; make noise
875            # if they do.
876            logging.error(
877                'Got BasicClientUI.ButtonLabel.UNKNOWN; should not happen.'
878            )
879            return babase.Lstr(value='<error>')
880
881        rsrc: str | None = None
882        if label is cls.OK:
883            rsrc = 'okText'
884        elif label is cls.APPLY:
885            rsrc = 'applyText'
886        elif label is cls.CANCEL:
887            rsrc = 'cancelText'
888        elif label is cls.ACCEPT:
889            rsrc = 'gatherWindow.partyInviteAcceptText'
890        elif label is cls.DECLINE:
891            rsrc = 'gatherWindow.partyInviteDeclineText'
892        elif label is cls.IGNORE:
893            rsrc = 'gatherWindow.partyInviteIgnoreText'
894        elif label is cls.CLAIM:
895            rsrc = 'claimText'
896        elif label is cls.DISCARD:
897            rsrc = 'discardText'
898        else:
899            assert_never(label)
900
901        return babase.Lstr(resource=rsrc)
902
903    def required_purchases_for_game(self, game: str) -> list[str]:
904        """Return which purchase (if any) is required for a game."""
905        # pylint: disable=too-many-return-statements
906
907        if game in (
908            'Challenges:Infinite Runaround',
909            'Challenges:Tournament Infinite Runaround',
910        ):
911            # Special case: Pro used to unlock this.
912            return (
913                []
914                if self.accounts.have_pro()
915                else ['upgrades.infinite_runaround']
916            )
917        if game in (
918            'Challenges:Infinite Onslaught',
919            'Challenges:Tournament Infinite Onslaught',
920        ):
921            # Special case: Pro used to unlock this.
922            return (
923                []
924                if self.accounts.have_pro()
925                else ['upgrades.infinite_onslaught']
926            )
927        if game in (
928            'Challenges:Meteor Shower',
929            'Challenges:Epic Meteor Shower',
930        ):
931            return ['games.meteor_shower']
932
933        if game in (
934            'Challenges:Target Practice',
935            'Challenges:Target Practice B',
936        ):
937            return ['games.target_practice']
938
939        if game in (
940            'Challenges:Ninja Fight',
941            'Challenges:Pro Ninja Fight',
942        ):
943            return ['games.ninja_fight']
944
945        if game in ('Challenges:Race', 'Challenges:Pro Race'):
946            return ['games.race']
947
948        if game in ('Challenges:Lake Frigid Race',):
949            return ['games.race', 'maps.lake_frigid']
950
951        if game in (
952            'Challenges:Easter Egg Hunt',
953            'Challenges:Pro Easter Egg Hunt',
954        ):
955            return ['games.easter_egg_hunt']
956
957        return []
958
959    def is_game_unlocked(self, game: str) -> bool:
960        """Is a particular game unlocked?"""
961        plus = babase.app.plus
962        assert plus is not None
963
964        purchases = self.required_purchases_for_game(game)
965        if not purchases:
966            return True
967
968        for purchase in purchases:
969            if not plus.get_v1_account_product_purchased(purchase):
970                return False
971        return True

Subsystem for classic functionality in the app.

The single shared instance of this app can be accessed at babase.app.classic. Note that it is possible for babase.app.classic to be None if the classic package is not present, and code should handle that case gracefully.

accounts
ads
ach
store
music
campaigns: dict[str, bascenev1.Campaign]
custom_coop_practice_games: list[str]
lobby_random_profile_index: int
lobby_random_char_index_offset
lobby_account_profile_device_id: int | None
tips: list[str]
stress_test_update_timer: _babase.AppTimer | None
stress_test_update_timer_2: _babase.AppTimer | None
value_test_defaults: dict
ping_thread_count
allow_ticket_purchases: bool
remove_ads
gold_pass
tokens
chest_dock_full
main_menu_did_initial_transition
main_menu_last_news_fetch_time: float | None
spaz_appearances: dict[str, bascenev1lib.actor.spazappearance.Appearance]
last_spaz_turbo_warn_time
server: baclassic._servermode.ServerController | None
log_have_new
log_upload_timer_started
printed_live_object_warning
input_map_hash: str | None
maps: dict[str, type[bascenev1.Map]]
teams_series_length
ffa_series_length
coop_session_args: dict
first_main_menu
did_menu_intro
main_menu_window_refresh_check_count
invite_confirm_windows: list[typing.Any]
party_window: weakref.ReferenceType[bauiv1lib.party.PartyWindow] | None
main_menu_resume_callbacks: list
saved_ui_state: bauiv1.MainWindowState | None
store_layout: dict[str, list[dict[str, typing.Any]]] | None
store_items: dict[str, dict] | None
pro_sale_start_time: int | None
pro_sale_start_val: int | None
platform: str
139    @property
140    def platform(self) -> str:
141        """Name of the current platform.
142
143        Examples are: 'mac', 'windows', android'.
144        """
145        assert isinstance(self._env['platform'], str)
146        return self._env['platform']

Name of the current platform.

Examples are: 'mac', 'windows', android'.

subplatform: str
152    @property
153    def subplatform(self) -> str:
154        """String for subplatform.
155
156        Can be empty. For the 'android' platform, subplatform may
157        be 'google', 'amazon', etc.
158        """
159        assert isinstance(self._env['subplatform'], str)
160        return self._env['subplatform']

String for subplatform.

Can be empty. For the 'android' platform, subplatform may be 'google', 'amazon', etc.

legacy_user_agent_string: str
162    @property
163    def legacy_user_agent_string(self) -> str:
164        """String containing various bits of info about OS/device/etc."""
165        assert isinstance(self._env['legacy_user_agent_string'], str)
166        return self._env['legacy_user_agent_string']

String containing various bits of info about OS/device/etc.

@override
def on_app_loading(self) -> None:
168    @override
169    def on_app_loading(self) -> None:
170        from bascenev1lib.actor import spazappearance
171        from bascenev1lib import maps as stdmaps
172
173        plus = babase.app.plus
174        assert plus is not None
175
176        env = babase.app.env
177        cfg = babase.app.config
178
179        self.music.on_app_loading()
180
181        # Non-test, non-debug builds should generally be blessed; warn
182        # if not (so I don't accidentally release a build that can't
183        # play tourneys).
184        if not env.debug and not env.test and not plus.is_blessed():
185            babase.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0))
186
187        stdmaps.register_all_maps()
188
189        spazappearance.register_appearances()
190        bascenev1.init_campaigns()
191
192        launch_count = cfg.get('launchCount', 0)
193        launch_count += 1
194
195        # So we know how many times we've run the game at various
196        # version milestones.
197        for key in ('lc14173', 'lc14292'):
198            cfg.setdefault(key, launch_count)
199
200        cfg['launchCount'] = launch_count
201        cfg.commit()
202
203        # If there's a leftover log file, attempt to upload it to the
204        # master-server and/or get rid of it.
205        babase.handle_leftover_v1_cloud_log_file()
206
207        self.accounts.on_app_loading()

Called when the app reaches the loading state.

Note that subsystems created after the app switches to the loading state will not receive this callback. Subsystems created by plugins are an example of this.

@override
def on_app_suspend(self) -> None:
209    @override
210    def on_app_suspend(self) -> None:
211        self.accounts.on_app_suspend()

Called when the app enters the suspended state.

@override
def on_app_unsuspend(self) -> None:
213    @override
214    def on_app_unsuspend(self) -> None:
215        self.accounts.on_app_unsuspend()
216        self.music.on_app_unsuspend()

Called when the app exits the suspended state.

@override
def on_app_shutdown(self) -> None:
218    @override
219    def on_app_shutdown(self) -> None:
220        self.music.on_app_shutdown()

Called when the app begins shutting down.

def pause(self) -> None:
222    def pause(self) -> None:
223        """Pause the game due to a user request or menu popping up.
224
225        If there's a foreground host-activity that says it's pausable, tell it
226        to pause. Note: we now no longer pause if there are connected clients.
227        """
228        activity: bascenev1.Activity | None = (
229            bascenev1.get_foreground_host_activity()
230        )
231        if (
232            activity is not None
233            and activity.allow_pausing
234            and not bascenev1.have_connected_clients()
235        ):
236            from babase import Lstr
237            from bascenev1 import NodeActor
238
239            # FIXME: Shouldn't be touching scene stuff here; should just
240            #  pass the request on to the host-session.
241            with activity.context:
242                globs = activity.globalsnode
243                if not globs.paused:
244                    bascenev1.getsound('refWhistle').play()
245                    globs.paused = True
246
247                # FIXME: This should not be an attr on Actor.
248                activity.paused_text = NodeActor(
249                    bascenev1.newnode(
250                        'text',
251                        attrs={
252                            'text': Lstr(resource='pausedByHostText'),
253                            'client_only': True,
254                            'flatness': 1.0,
255                            'h_align': 'center',
256                        },
257                    )
258                )

Pause the game due to a user request or menu popping up.

If there's a foreground host-activity that says it's pausable, tell it to pause. Note: we now no longer pause if there are connected clients.

def resume(self) -> None:
260    def resume(self) -> None:
261        """Resume the game due to a user request or menu closing.
262
263        If there's a foreground host-activity that's currently paused, tell it
264        to resume.
265        """
266
267        # FIXME: Shouldn't be touching scene stuff here; should just
268        #  pass the request on to the host-session.
269        activity = bascenev1.get_foreground_host_activity()
270        if activity is not None:
271            with activity.context:
272                globs = activity.globalsnode
273                if globs.paused:
274                    bascenev1.getsound('refWhistle').play()
275                    globs.paused = False
276
277                    # FIXME: This should not be an actor attr.
278                    activity.paused_text = None

Resume the game due to a user request or menu closing.

If there's a foreground host-activity that's currently paused, tell it to resume.

def add_coop_practice_level(self, level: bascenev1.Level) -> None:
280    def add_coop_practice_level(self, level: bascenev1.Level) -> None:
281        """Adds an individual level to the 'practice' section in Co-op."""
282
283        # Assign this level to our catch-all campaign.
284        self.campaigns['Challenges'].addlevel(level)
285
286        # Make note to add it to our challenges UI.
287        self.custom_coop_practice_games.append(f'Challenges:{level.name}')

Adds an individual level to the 'practice' section in Co-op.

def launch_coop_game(self, game: str, force: bool = False, args: dict | None = None) -> bool:
289    def launch_coop_game(
290        self, game: str, force: bool = False, args: dict | None = None
291    ) -> bool:
292        """High level way to launch a local co-op session."""
293        # pylint: disable=cyclic-import
294        from bauiv1lib.coop.level import CoopLevelLockedWindow
295
296        assert babase.app.classic is not None
297
298        if args is None:
299            args = {}
300        if game == '':
301            raise ValueError('empty game name')
302        campaignname, levelname = game.split(':')
303        campaign = babase.app.classic.getcampaign(campaignname)
304
305        # If this campaign is sequential, make sure we've completed the
306        # one before this.
307        if campaign.sequential and not force:
308            for level in campaign.levels:
309                if level.name == levelname:
310                    break
311                if not level.complete:
312                    CoopLevelLockedWindow(
313                        campaign.getlevel(levelname).displayname,
314                        campaign.getlevel(level.name).displayname,
315                    )
316                    return False
317
318        # Save where we are in the UI to come back to when done.
319        babase.app.classic.save_ui_state()
320
321        # Ok, we're good to go.
322        self.coop_session_args = {
323            'campaign': campaignname,
324            'level': levelname,
325        }
326        for arg_name, arg_val in list(args.items()):
327            self.coop_session_args[arg_name] = arg_val
328
329        def _fade_end() -> None:
330            from bascenev1 import CoopSession
331
332            try:
333                bascenev1.new_host_session(CoopSession)
334            except Exception:
335                logging.exception('Error creating coopsession after fade end.')
336                from bascenev1lib.mainmenu import MainMenuSession
337
338                bascenev1.new_host_session(MainMenuSession)
339
340        babase.fade_screen(False, endcall=_fade_end)
341        return True

High level way to launch a local co-op session.

def return_to_main_menu_session_gracefully(self, reset_ui: bool = True) -> None:
343    def return_to_main_menu_session_gracefully(
344        self, reset_ui: bool = True
345    ) -> None:
346        """Attempt to cleanly get back to the main menu."""
347        # pylint: disable=cyclic-import
348        from baclassic import _benchmark
349        from bascenev1lib.mainmenu import MainMenuSession
350
351        plus = babase.app.plus
352        assert plus is not None
353
354        if reset_ui:
355            babase.app.ui_v1.clear_main_window()
356
357        if isinstance(bascenev1.get_foreground_host_session(), MainMenuSession):
358            # It may be possible we're on the main menu but the screen
359            # is faded so fade back in.
360            babase.fade_screen(True)
361            return
362
363        _benchmark.stop_stress_test()  # Stop stress-test if in progress.
364
365        # If we're in a host-session, tell them to end. This lets them
366        # tear themselves down gracefully.
367        host_session: bascenev1.Session | None = (
368            bascenev1.get_foreground_host_session()
369        )
370        if host_session is not None:
371            # Kick off a little transaction so we'll hopefully have all
372            # the latest account state when we get back to the menu.
373            plus.add_v1_account_transaction(
374                {'type': 'END_SESSION', 'sType': str(type(host_session))}
375            )
376            plus.run_v1_account_transactions()
377
378            host_session.end()
379
380        # Otherwise just force the issue.
381        else:
382            babase.pushcall(
383                babase.Call(bascenev1.new_host_session, MainMenuSession)
384            )

Attempt to cleanly get back to the main menu.

def getmaps(self, playtype: str) -> list[str]:
386    def getmaps(self, playtype: str) -> list[str]:
387        """Return a list of bascenev1.Map types supporting a playtype str.
388
389        Maps supporting a given playtype must provide a particular set of
390        features and lend themselves to a certain style of play.
391
392        Play Types:
393
394        'melee'
395          General fighting map.
396          Has one or more 'spawn' locations.
397
398        'team_flag'
399          For games such as Capture The Flag where each team spawns by a flag.
400          Has two or more 'spawn' locations, each with a corresponding 'flag'
401          location (based on index).
402
403        'single_flag'
404          For games such as King of the Hill or Keep Away where multiple teams
405          are fighting over a single flag.
406          Has two or more 'spawn' locations and 1 'flag_default' location.
407
408        'conquest'
409          For games such as Conquest where flags are spread throughout the map
410          - has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations.
411
412        'king_of_the_hill' - has 2+ 'spawn' locations,
413           1+ 'flag_default' locations, and 1+ 'powerup_spawn' locations
414
415        'hockey'
416          For hockey games.
417          Has two 'goal' locations, corresponding 'spawn' locations, and one
418          'flag_default' location (for where puck spawns)
419
420        'football'
421          For football games.
422          Has two 'goal' locations, corresponding 'spawn' locations, and one
423          'flag_default' location (for where flag/ball/etc. spawns)
424
425        'race'
426          For racing games where players much touch each region in order.
427          Has two or more 'race_point' locations.
428        """
429        return sorted(
430            key
431            for key, val in self.maps.items()
432            if playtype in val.get_play_types()
433        )

Return a list of bascenev1.Map types supporting a playtype str.

Maps supporting a given playtype must provide a particular set of features and lend themselves to a certain style of play.

Play Types:

'melee' General fighting map. Has one or more 'spawn' locations.

'team_flag' For games such as Capture The Flag where each team spawns by a flag. Has two or more 'spawn' locations, each with a corresponding 'flag' location (based on index).

'single_flag' For games such as King of the Hill or Keep Away where multiple teams are fighting over a single flag. Has two or more 'spawn' locations and 1 'flag_default' location.

'conquest' For games such as Conquest where flags are spread throughout the map

  • has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations.

'king_of_the_hill' - has 2+ 'spawn' locations, 1+ 'flag_default' locations, and 1+ 'powerup_spawn' locations

'hockey' For hockey games. Has two 'goal' locations, corresponding 'spawn' locations, and one 'flag_default' location (for where puck spawns)

'football' For football games. Has two 'goal' locations, corresponding 'spawn' locations, and one 'flag_default' location (for where flag/ball/etc. spawns)

'race' For racing games where players much touch each region in order. Has two or more 'race_point' locations.

@classmethod
def json_prep(cls, data: Any) -> Any:
441    @classmethod
442    def json_prep(cls, data: Any) -> Any:
443        """Return a json-friendly version of the provided data.
444
445        This converts any tuples to lists and any bytes to strings
446        (interpreted as utf-8, ignoring errors). Logs errors (just once)
447        if any data is modified/discarded/unsupported.
448        """
449
450        if isinstance(data, dict):
451            return dict(
452                (cls.json_prep(key), cls.json_prep(value))
453                for key, value in list(data.items())
454            )
455        if isinstance(data, list):
456            return [cls.json_prep(element) for element in data]
457        if isinstance(data, tuple):
458            logging.exception('json_prep encountered tuple')
459            return [cls.json_prep(element) for element in data]
460        if isinstance(data, bytes):
461            try:
462                return data.decode(errors='ignore')
463            except Exception:
464                logging.exception('json_prep encountered utf-8 decode error')
465                return data.decode(errors='ignore')
466        if not isinstance(data, (str, float, bool, type(None), int)):
467            logging.exception(
468                'got unsupported type in json_prep: %s', type(data)
469            )
470        return data

Return a json-friendly version of the provided data.

This converts any tuples to lists and any bytes to strings (interpreted as utf-8, ignoring errors). Logs errors (just once) if any data is modified/discarded/unsupported.

def master_server_v1_get( self, request: str, data: dict[str, typing.Any], callback: Optional[Callable[[None | dict[str, Any]], NoneType]] = None, response_type: baclassic._net.MasterServerResponseType = <MasterServerResponseType.JSON: 0>) -> None:
472    def master_server_v1_get(
473        self,
474        request: str,
475        data: dict[str, Any],
476        callback: MasterServerCallback | None = None,
477        response_type: MasterServerResponseType = MasterServerResponseType.JSON,
478    ) -> None:
479        """Make a call to the master server via a http GET."""
480
481        MasterServerV1CallThread(
482            request, 'get', data, callback, response_type
483        ).start()

Make a call to the master server via a http GET.

def master_server_v1_post( self, request: str, data: dict[str, typing.Any], callback: Optional[Callable[[None | dict[str, Any]], NoneType]] = None, response_type: baclassic._net.MasterServerResponseType = <MasterServerResponseType.JSON: 0>) -> None:
485    def master_server_v1_post(
486        self,
487        request: str,
488        data: dict[str, Any],
489        callback: MasterServerCallback | None = None,
490        response_type: MasterServerResponseType = MasterServerResponseType.JSON,
491    ) -> None:
492        """Make a call to the master server via a http POST."""
493        MasterServerV1CallThread(
494            request, 'post', data, callback, response_type
495        ).start()

Make a call to the master server via a http POST.

def set_tournament_prize_image( self, entry: dict[str, typing.Any], index: int, image: _bauiv1.Widget) -> None:
497    def set_tournament_prize_image(
498        self, entry: dict[str, Any], index: int, image: bauiv1.Widget
499    ) -> None:
500        """Given a tournament entry, return strings for its prize levels."""
501        from baclassic import _tournament
502
503        return _tournament.set_tournament_prize_chest_image(entry, index, image)

Given a tournament entry, return strings for its prize levels.

def create_in_game_tournament_prize_image( self, entry: dict[str, typing.Any], index: int, position: tuple[float, float]) -> None:
505    def create_in_game_tournament_prize_image(
506        self,
507        entry: dict[str, Any],
508        index: int,
509        position: tuple[float, float],
510    ) -> None:
511        """Given a tournament entry, return strings for its prize levels."""
512        from baclassic import _tournament
513
514        _tournament.create_in_game_tournament_prize_image(
515            entry, index, position
516        )

Given a tournament entry, return strings for its prize levels.

def get_tournament_prize_strings(self, entry: dict[str, typing.Any], include_tickets: bool) -> list[str]:
518    def get_tournament_prize_strings(
519        self, entry: dict[str, Any], include_tickets: bool
520    ) -> list[str]:
521        """Given a tournament entry, return strings for its prize levels."""
522        from baclassic import _tournament
523
524        return _tournament.get_tournament_prize_strings(
525            entry, include_tickets=include_tickets
526        )

Given a tournament entry, return strings for its prize levels.

def getcampaign(self, name: str) -> bascenev1.Campaign:
528    def getcampaign(self, name: str) -> bascenev1.Campaign:
529        """Return a campaign by name."""
530        return self.campaigns[name]

Return a campaign by name.

def get_next_tip(self) -> str:
532    def get_next_tip(self) -> str:
533        """Returns the next tip to be displayed."""
534        if not self.tips:
535            for tip in get_all_tips():
536                self.tips.insert(random.randint(0, len(self.tips)), tip)
537        tip = self.tips.pop()
538        return tip

Returns the next tip to be displayed.

def run_cpu_benchmark(self) -> None:
540    def run_cpu_benchmark(self) -> None:
541        """Kick off a benchmark to test cpu speeds."""
542        from baclassic._benchmark import run_cpu_benchmark
543
544        run_cpu_benchmark()

Kick off a benchmark to test cpu speeds.

def run_media_reload_benchmark(self) -> None:
546    def run_media_reload_benchmark(self) -> None:
547        """Kick off a benchmark to test media reloading speeds."""
548        from baclassic._benchmark import run_media_reload_benchmark
549
550        run_media_reload_benchmark()

Kick off a benchmark to test media reloading speeds.

def run_stress_test( self, *, playlist_type: str = 'Random', playlist_name: str = '__default__', player_count: int = 8, round_duration: int = 30, attract_mode: bool = False) -> None:
552    def run_stress_test(
553        self,
554        *,
555        playlist_type: str = 'Random',
556        playlist_name: str = '__default__',
557        player_count: int = 8,
558        round_duration: int = 30,
559        attract_mode: bool = False,
560    ) -> None:
561        """Run a stress test."""
562        from baclassic._benchmark import run_stress_test
563
564        run_stress_test(
565            playlist_type=playlist_type,
566            playlist_name=playlist_name,
567            player_count=player_count,
568            round_duration=round_duration,
569            attract_mode=attract_mode,
570        )

Run a stress test.

def get_input_device_mapped_value( self, device: _bascenev1.InputDevice, name: str, default: bool = False) -> Any:
572    def get_input_device_mapped_value(
573        self,
574        device: bascenev1.InputDevice,
575        name: str,
576        default: bool = False,
577    ) -> Any:
578        """Return a mapped value for an input device.
579
580        This checks the user config and falls back to default values
581        where available.
582        """
583        return _input.get_input_device_mapped_value(
584            device.name, device.unique_identifier, name, default
585        )

Return a mapped value for an input device.

This checks the user config and falls back to default values where available.

def get_input_device_map_hash(self, inputdevice: _bascenev1.InputDevice) -> str:
587    def get_input_device_map_hash(
588        self, inputdevice: bascenev1.InputDevice
589    ) -> str:
590        """Given an input device, return hash based on its raw input values."""
591        del inputdevice  # unused currently
592        return _input.get_input_device_map_hash()

Given an input device, return hash based on its raw input values.

def get_input_device_config( self, inputdevice: _bascenev1.InputDevice, default: bool) -> tuple[dict, str]:
594    def get_input_device_config(
595        self, inputdevice: bascenev1.InputDevice, default: bool
596    ) -> tuple[dict, str]:
597        """Given an input device, return its config dict in the app config.
598
599        The dict will be created if it does not exist.
600        """
601        return _input.get_input_device_config(
602            inputdevice.name, inputdevice.unique_identifier, default
603        )

Given an input device, return its config dict in the app config.

The dict will be created if it does not exist.

def get_player_colors(self) -> list[tuple[float, float, float]]:
605    def get_player_colors(self) -> list[tuple[float, float, float]]:
606        """Return user-selectable player colors."""
607        return bascenev1.get_player_colors()

Return user-selectable player colors.

def get_player_profile_icon(self, profilename: str) -> str:
609    def get_player_profile_icon(self, profilename: str) -> str:
610        """Given a profile name, returns an icon string for it.
611
612        (non-account profiles only)
613        """
614        return bascenev1.get_player_profile_icon(profilename)

Given a profile name, returns an icon string for it.

(non-account profiles only)

def get_player_profile_colors( self, profilename: str | None, profiles: dict[str, dict[str, typing.Any]] | None = None) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
616    def get_player_profile_colors(
617        self,
618        profilename: str | None,
619        profiles: dict[str, dict[str, Any]] | None = None,
620    ) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
621        """Given a profile, return colors for them."""
622        return bascenev1.get_player_profile_colors(profilename, profiles)

Given a profile, return colors for them.

def preload_map_preview_media(self) -> None:
753    def preload_map_preview_media(self) -> None:
754        """Preload media needed for map preview UIs."""
755        try:
756            bauiv1.getmesh('level_select_button_opaque')
757            bauiv1.getmesh('level_select_button_transparent')
758            for maptype in list(self.maps.values()):
759                map_tex_name = maptype.get_preview_texture_name()
760                if map_tex_name is not None:
761                    bauiv1.gettexture(map_tex_name)
762        except Exception:
763            logging.exception('Error preloading map preview media.')

Preload media needed for map preview UIs.

def save_ui_state(self) -> None:
809    def save_ui_state(self) -> None:
810        """Store our current place in the UI."""
811        ui = babase.app.ui_v1
812        mainwindow = ui.get_main_window()
813        if mainwindow is not None:
814            self.saved_ui_state = ui.save_main_window_state(mainwindow)
815        else:
816            self.saved_ui_state = None

Store our current place in the UI.

def invoke_main_menu_ui(self) -> None:
818    def invoke_main_menu_ui(self) -> None:
819        """Bring up main menu ui."""
820
821        # Bring up the last place we were, or start at the main menu
822        # otherwise.
823        app = bauiv1.app
824        env = app.env
825        with bascenev1.ContextRef.empty():
826
827            assert app.classic is not None
828            if app.env.headless:
829                # UI stuff fails now in headless builds; avoid it.
830                pass
831            else:
832
833                # When coming back from a kiosk-mode game, jump to the
834                # kiosk start screen.
835                if env.demo or env.arcade:
836                    # pylint: disable=cyclic-import
837                    from bauiv1lib.kiosk import KioskWindow
838
839                    app.ui_v1.set_main_window(
840                        KioskWindow(), is_top_level=True, suppress_warning=True
841                    )
842                else:
843                    # If there's a saved ui state, restore that.
844                    if self.saved_ui_state is not None:
845                        app.ui_v1.restore_main_window_state(self.saved_ui_state)
846                    else:
847                        # Otherwise start fresh at the main menu.
848                        from bauiv1lib.mainmenu import MainMenuWindow
849
850                        app.ui_v1.set_main_window(
851                            MainMenuWindow(transition=None),
852                            is_top_level=True,
853                            suppress_warning=True,
854                        )

Bring up main menu ui.

@staticmethod
def run_bs_client_effects(effects: list[bacommon.bs.ClientEffect], delay: float = 0.0) -> None:
856    @staticmethod
857    def run_bs_client_effects(
858        effects: list[bacommon.bs.ClientEffect], delay: float = 0.0
859    ) -> None:
860        """Run client effects sent from the master server."""
861        from baclassic._clienteffect import run_bs_client_effects
862
863        run_bs_client_effects(effects, delay=delay)

Run client effects sent from the master server.

@staticmethod
def basic_client_ui_button_label_str(label: bacommon.bs.BasicClientUI.ButtonLabel) -> babase.Lstr:
865    @staticmethod
866    def basic_client_ui_button_label_str(
867        label: bacommon.bs.BasicClientUI.ButtonLabel,
868    ) -> babase.Lstr:
869        """Given a client-ui label, return an Lstr."""
870        import bacommon.bs
871
872        cls = bacommon.bs.BasicClientUI.ButtonLabel
873        if label is cls.UNKNOWN:
874            # Server should not be sending us unknown stuff; make noise
875            # if they do.
876            logging.error(
877                'Got BasicClientUI.ButtonLabel.UNKNOWN; should not happen.'
878            )
879            return babase.Lstr(value='<error>')
880
881        rsrc: str | None = None
882        if label is cls.OK:
883            rsrc = 'okText'
884        elif label is cls.APPLY:
885            rsrc = 'applyText'
886        elif label is cls.CANCEL:
887            rsrc = 'cancelText'
888        elif label is cls.ACCEPT:
889            rsrc = 'gatherWindow.partyInviteAcceptText'
890        elif label is cls.DECLINE:
891            rsrc = 'gatherWindow.partyInviteDeclineText'
892        elif label is cls.IGNORE:
893            rsrc = 'gatherWindow.partyInviteIgnoreText'
894        elif label is cls.CLAIM:
895            rsrc = 'claimText'
896        elif label is cls.DISCARD:
897            rsrc = 'discardText'
898        else:
899            assert_never(label)
900
901        return babase.Lstr(resource=rsrc)

Given a client-ui label, return an Lstr.

def required_purchases_for_game(self, game: str) -> list[str]:
903    def required_purchases_for_game(self, game: str) -> list[str]:
904        """Return which purchase (if any) is required for a game."""
905        # pylint: disable=too-many-return-statements
906
907        if game in (
908            'Challenges:Infinite Runaround',
909            'Challenges:Tournament Infinite Runaround',
910        ):
911            # Special case: Pro used to unlock this.
912            return (
913                []
914                if self.accounts.have_pro()
915                else ['upgrades.infinite_runaround']
916            )
917        if game in (
918            'Challenges:Infinite Onslaught',
919            'Challenges:Tournament Infinite Onslaught',
920        ):
921            # Special case: Pro used to unlock this.
922            return (
923                []
924                if self.accounts.have_pro()
925                else ['upgrades.infinite_onslaught']
926            )
927        if game in (
928            'Challenges:Meteor Shower',
929            'Challenges:Epic Meteor Shower',
930        ):
931            return ['games.meteor_shower']
932
933        if game in (
934            'Challenges:Target Practice',
935            'Challenges:Target Practice B',
936        ):
937            return ['games.target_practice']
938
939        if game in (
940            'Challenges:Ninja Fight',
941            'Challenges:Pro Ninja Fight',
942        ):
943            return ['games.ninja_fight']
944
945        if game in ('Challenges:Race', 'Challenges:Pro Race'):
946            return ['games.race']
947
948        if game in ('Challenges:Lake Frigid Race',):
949            return ['games.race', 'maps.lake_frigid']
950
951        if game in (
952            'Challenges:Easter Egg Hunt',
953            'Challenges:Pro Easter Egg Hunt',
954        ):
955            return ['games.easter_egg_hunt']
956
957        return []

Return which purchase (if any) is required for a game.

def is_game_unlocked(self, game: str) -> bool:
959    def is_game_unlocked(self, game: str) -> bool:
960        """Is a particular game unlocked?"""
961        plus = babase.app.plus
962        assert plus is not None
963
964        purchases = self.required_purchases_for_game(game)
965        if not purchases:
966            return True
967
968        for purchase in purchases:
969            if not plus.get_v1_account_product_purchased(purchase):
970                return False
971        return True

Is a particular game unlocked?

class ClassicAppSubsystem.MusicPlayMode(enum.Enum):
23class MusicPlayMode(Enum):
24    """Influences behavior when playing music."""
25
26    REGULAR = 'regular'
27    TEST = 'test'

Influences behavior when playing music.

class Achievement:
 531class Achievement:
 532    """Represents attributes and state for an individual achievement."""
 533
 534    def __init__(
 535        self,
 536        name: str,
 537        icon_name: str,
 538        icon_color: tuple[float, float, float],
 539        level_name: str,
 540        *,
 541        award: int,
 542        hard_mode_only: bool = False,
 543    ):
 544        self._name = name
 545        self._icon_name = icon_name
 546        assert len(icon_color) == 3
 547        self._icon_color = icon_color + (1.0,)
 548        self._level_name = level_name
 549        self._completion_banner_slot: int | None = None
 550        self._award = award
 551        self._hard_mode_only = hard_mode_only
 552
 553    @property
 554    def name(self) -> str:
 555        """The name of this achievement."""
 556        return self._name
 557
 558    @property
 559    def level_name(self) -> str:
 560        """The name of the level this achievement applies to."""
 561        return self._level_name
 562
 563    def get_icon_ui_texture(self, complete: bool) -> bauiv1.Texture:
 564        """Return the icon texture to display for this achievement"""
 565        return bauiv1.gettexture(
 566            self._icon_name if complete else 'achievementEmpty'
 567        )
 568
 569    def get_icon_texture(self, complete: bool) -> bascenev1.Texture:
 570        """Return the icon texture to display for this achievement"""
 571        return bascenev1.gettexture(
 572            self._icon_name if complete else 'achievementEmpty'
 573        )
 574
 575    def get_icon_color(self, complete: bool) -> Sequence[float]:
 576        """Return the color tint for this Achievement's icon."""
 577        if complete:
 578            return self._icon_color
 579        return 1.0, 1.0, 1.0, 0.6
 580
 581    @property
 582    def hard_mode_only(self) -> bool:
 583        """Whether this Achievement is only unlockable in hard-mode."""
 584        return self._hard_mode_only
 585
 586    @property
 587    def complete(self) -> bool:
 588        """Whether this Achievement is currently complete."""
 589        val: bool = self._getconfig()['Complete']
 590        assert isinstance(val, bool)
 591        return val
 592
 593    def announce_completion(self, sound: bool = True) -> None:
 594        """Kick off an announcement for this achievement's completion."""
 595
 596        app = babase.app
 597        plus = app.plus
 598        classic = app.classic
 599        if plus is None or classic is None:
 600            logging.warning('ach account_completion not available.')
 601            return
 602
 603        ach_ss = classic.ach
 604
 605        # Even though there are technically achievements when we're not
 606        # signed in, lets not show them (otherwise we tend to get
 607        # confusing 'controller connected' achievements popping up while
 608        # waiting to sign in which can be confusing).
 609        if plus.get_v1_account_state() != 'signed_in':
 610            return
 611
 612        # If we're being freshly complete, display/report it and whatnot.
 613        if (self, sound) not in ach_ss.achievements_to_display:
 614            ach_ss.achievements_to_display.append((self, sound))
 615
 616        # If there's no achievement display timer going, kick one off
 617        # (if one's already running it will pick this up before it dies).
 618
 619        # Need to check last time too; its possible our timer wasn't able to
 620        # clear itself if an activity died and took it down with it.
 621        if (
 622            ach_ss.achievement_display_timer is None
 623            or babase.apptime() - ach_ss.last_achievement_display_time > 2.0
 624        ) and bascenev1.getactivity(doraise=False) is not None:
 625            ach_ss.achievement_display_timer = bascenev1.BaseTimer(
 626                1.0, _display_next_achievement, repeat=True
 627            )
 628
 629            # Show the first immediately.
 630            _display_next_achievement()
 631
 632    def set_complete(self, complete: bool = True) -> None:
 633        """Set an achievement's completed state.
 634
 635        note this only sets local state; use a transaction to
 636        actually award achievements.
 637        """
 638        config = self._getconfig()
 639        if complete != config['Complete']:
 640            config['Complete'] = complete
 641
 642    @property
 643    def display_name(self) -> babase.Lstr:
 644        """Return a babase.Lstr for this Achievement's name."""
 645        name: babase.Lstr | str
 646        try:
 647            if self._level_name != '':
 648                campaignname, campaign_level = self._level_name.split(':')
 649                classic = babase.app.classic
 650                assert classic is not None
 651                name = (
 652                    classic.getcampaign(campaignname)
 653                    .getlevel(campaign_level)
 654                    .displayname
 655                )
 656            else:
 657                name = ''
 658        except Exception:
 659            name = ''
 660            logging.exception('Error calcing achievement display-name.')
 661        return babase.Lstr(
 662            resource='achievements.' + self._name + '.name',
 663            subs=[('${LEVEL}', name)],
 664        )
 665
 666    @property
 667    def description(self) -> babase.Lstr:
 668        """Get a babase.Lstr for the Achievement's brief description."""
 669        if (
 670            'description'
 671            in babase.app.lang.get_resource('achievements')[self._name]
 672        ):
 673            return babase.Lstr(
 674                resource='achievements.' + self._name + '.description'
 675            )
 676        return babase.Lstr(
 677            resource='achievements.' + self._name + '.descriptionFull'
 678        )
 679
 680    @property
 681    def description_complete(self) -> babase.Lstr:
 682        """Get a babase.Lstr for the Achievement's description when complete."""
 683        if (
 684            'descriptionComplete'
 685            in babase.app.lang.get_resource('achievements')[self._name]
 686        ):
 687            return babase.Lstr(
 688                resource='achievements.' + self._name + '.descriptionComplete'
 689            )
 690        return babase.Lstr(
 691            resource='achievements.' + self._name + '.descriptionFullComplete'
 692        )
 693
 694    @property
 695    def description_full(self) -> babase.Lstr:
 696        """Get a babase.Lstr for the Achievement's full description."""
 697        return babase.Lstr(
 698            resource='achievements.' + self._name + '.descriptionFull',
 699            subs=[
 700                (
 701                    '${LEVEL}',
 702                    babase.Lstr(
 703                        translate=(
 704                            'coopLevelNames',
 705                            ACH_LEVEL_NAMES.get(self._name, '?'),
 706                        )
 707                    ),
 708                )
 709            ],
 710        )
 711
 712    @property
 713    def description_full_complete(self) -> babase.Lstr:
 714        """Get a babase.Lstr for the Achievement's full desc. when completed."""
 715        return babase.Lstr(
 716            resource='achievements.' + self._name + '.descriptionFullComplete',
 717            subs=[
 718                (
 719                    '${LEVEL}',
 720                    babase.Lstr(
 721                        translate=(
 722                            'coopLevelNames',
 723                            ACH_LEVEL_NAMES.get(self._name, '?'),
 724                        )
 725                    ),
 726                )
 727            ],
 728        )
 729
 730    def get_award_chest_type(self) -> ClassicChestAppearance:
 731        """Return the type of chest given for this achievement."""
 732
 733        # For now just map our old ticket values to chest types.
 734        # Can add distinct values if need be later.
 735        plus = babase.app.plus
 736        assert plus is not None
 737        t = plus.get_v1_account_misc_read_val(
 738            f'achAward.{self.name}', self._award
 739        )
 740        return (
 741            ClassicChestAppearance.L6
 742            if t >= 30
 743            else (
 744                ClassicChestAppearance.L5
 745                if t >= 25
 746                else (
 747                    ClassicChestAppearance.L4
 748                    if t >= 20
 749                    else (
 750                        ClassicChestAppearance.L3
 751                        if t >= 15
 752                        else (
 753                            ClassicChestAppearance.L2
 754                            if t >= 10
 755                            else ClassicChestAppearance.L1
 756                        )
 757                    )
 758                )
 759            )
 760        )
 761
 762    # def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int:
 763    #     """Get the ticket award value for this achievement."""
 764    #     plus = babase.app.plus
 765    #     if plus is None:
 766    #         return 0
 767    #     val: int = plus.get_v1_account_misc_read_val(
 768    #         'achAward.' + self._name, self._award
 769    #     ) * _get_ach_mult(include_pro_bonus)
 770    #     assert isinstance(val, int)
 771    #     return val
 772
 773    @property
 774    def power_ranking_value(self) -> int:
 775        """Get the power-ranking award value for this achievement."""
 776        plus = babase.app.plus
 777        if plus is None:
 778            return 0
 779        val: int = plus.get_v1_account_misc_read_val(
 780            'achLeaguePoints.' + self._name, self._award
 781        )
 782        assert isinstance(val, int)
 783        return val
 784
 785    def create_display(
 786        self,
 787        x: float,
 788        y: float,
 789        delay: float,
 790        *,
 791        outdelay: float | None = None,
 792        color: Sequence[float] | None = None,
 793        style: str = 'post_game',
 794    ) -> list[bascenev1.Actor]:
 795        """Create a display for the Achievement.
 796
 797        Shows the Achievement icon, name, and description.
 798        """
 799        # pylint: disable=cyclic-import
 800        from bascenev1 import CoopSession
 801        from bascenev1lib.actor.image import Image
 802        from bascenev1lib.actor.text import Text
 803
 804        # Yeah this needs cleaning up.
 805        if style == 'post_game':
 806            in_game_colors = False
 807            in_main_menu = False
 808            h_attach = Text.HAttach.CENTER
 809            v_attach = Text.VAttach.CENTER
 810            attach = Image.Attach.CENTER
 811        elif style == 'in_game':
 812            in_game_colors = True
 813            in_main_menu = False
 814            h_attach = Text.HAttach.LEFT
 815            v_attach = Text.VAttach.TOP
 816            attach = Image.Attach.TOP_LEFT
 817        elif style == 'news':
 818            in_game_colors = True
 819            in_main_menu = True
 820            h_attach = Text.HAttach.CENTER
 821            v_attach = Text.VAttach.TOP
 822            attach = Image.Attach.TOP_CENTER
 823        else:
 824            raise ValueError('invalid style "' + style + '"')
 825
 826        # Attempt to determine what campaign we're in
 827        # (so we know whether to show "hard mode only").
 828        if in_main_menu:
 829            hmo = False
 830        else:
 831            try:
 832                session = bascenev1.getsession()
 833                if isinstance(session, CoopSession):
 834                    campaign = session.campaign
 835                    assert campaign is not None
 836                    hmo = self._hard_mode_only and campaign.name == 'Easy'
 837                else:
 838                    hmo = False
 839            except Exception:
 840                logging.exception('Error determining campaign.')
 841                hmo = False
 842
 843        objs: list[bascenev1.Actor]
 844
 845        if in_game_colors:
 846            objs = []
 847            out_delay_fin = (delay + outdelay) if outdelay is not None else None
 848            if color is not None:
 849                cl1 = (2.0 * color[0], 2.0 * color[1], 2.0 * color[2], color[3])
 850                cl2 = color
 851            else:
 852                cl1 = (1.5, 1.5, 2, 1.0)
 853                cl2 = (0.8, 0.8, 1.0, 1.0)
 854
 855            if hmo:
 856                cl1 = (cl1[0], cl1[1], cl1[2], cl1[3] * 0.6)
 857                cl2 = (cl2[0], cl2[1], cl2[2], cl2[3] * 0.2)
 858
 859            objs.append(
 860                Image(
 861                    self.get_icon_texture(False),
 862                    host_only=True,
 863                    color=cl1,
 864                    position=(x - 25, y + 5),
 865                    attach=attach,
 866                    transition=Image.Transition.FADE_IN,
 867                    transition_delay=delay,
 868                    vr_depth=4,
 869                    transition_out_delay=out_delay_fin,
 870                    scale=(40, 40),
 871                ).autoretain()
 872            )
 873            txt = self.display_name
 874            txt_s = 0.85
 875            txt_max_w = 300
 876            objs.append(
 877                Text(
 878                    txt,
 879                    host_only=True,
 880                    maxwidth=txt_max_w,
 881                    position=(x, y + 2),
 882                    transition=Text.Transition.FADE_IN,
 883                    scale=txt_s,
 884                    flatness=0.6,
 885                    shadow=0.5,
 886                    h_attach=h_attach,
 887                    v_attach=v_attach,
 888                    color=cl2,
 889                    transition_delay=delay + 0.05,
 890                    transition_out_delay=out_delay_fin,
 891                ).autoretain()
 892            )
 893            txt2_s = 0.62
 894            txt2_max_w = 400
 895            objs.append(
 896                Text(
 897                    self.description_full if in_main_menu else self.description,
 898                    host_only=True,
 899                    maxwidth=txt2_max_w,
 900                    position=(x, y - 14),
 901                    transition=Text.Transition.FADE_IN,
 902                    vr_depth=-5,
 903                    h_attach=h_attach,
 904                    v_attach=v_attach,
 905                    scale=txt2_s,
 906                    flatness=1.0,
 907                    shadow=0.5,
 908                    color=cl2,
 909                    transition_delay=delay + 0.1,
 910                    transition_out_delay=out_delay_fin,
 911                ).autoretain()
 912            )
 913
 914            if hmo:
 915                txtactor = Text(
 916                    babase.Lstr(resource='difficultyHardOnlyText'),
 917                    host_only=True,
 918                    maxwidth=txt2_max_w * 0.7,
 919                    position=(x + 60, y + 5),
 920                    transition=Text.Transition.FADE_IN,
 921                    vr_depth=-5,
 922                    h_attach=h_attach,
 923                    v_attach=v_attach,
 924                    h_align=Text.HAlign.CENTER,
 925                    v_align=Text.VAlign.CENTER,
 926                    scale=txt_s * 0.8,
 927                    flatness=1.0,
 928                    shadow=0.5,
 929                    color=(1, 1, 0.6, 1),
 930                    transition_delay=delay + 0.1,
 931                    transition_out_delay=out_delay_fin,
 932                ).autoretain()
 933                txtactor.node.rotate = 10
 934                objs.append(txtactor)
 935
 936            # Chest award.
 937            award_x = -100
 938            chesttype = self.get_award_chest_type()
 939            chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get(
 940                chesttype, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT
 941            )
 942            objs.append(
 943                Image(
 944                    # Provide magical extended dict version of texture
 945                    # that Image actor supports.
 946                    texture={
 947                        'texture': bascenev1.gettexture(
 948                            chestdisplayinfo.texclosed
 949                        ),
 950                        'tint_texture': bascenev1.gettexture(
 951                            chestdisplayinfo.texclosedtint
 952                        ),
 953                        'tint_color': chestdisplayinfo.tint,
 954                        'tint2_color': chestdisplayinfo.tint2,
 955                        'mask_texture': None,
 956                    },
 957                    color=chestdisplayinfo.color + (0.5 if hmo else 1.0,),
 958                    position=(x + award_x + 37, y + 12),
 959                    scale=(32.0, 32.0),
 960                    transition=Image.Transition.FADE_IN,
 961                    transition_delay=delay + 0.05,
 962                    transition_out_delay=out_delay_fin,
 963                    host_only=True,
 964                    attach=Image.Attach.TOP_LEFT,
 965                ).autoretain()
 966            )
 967
 968            # objs.append(
 969            #     Text(
 970            #         babase.charstr(babase.SpecialChar.TICKET),
 971            #         host_only=True,
 972            #         position=(x + award_x + 33, y + 7),
 973            #         transition=Text.Transition.FADE_IN,
 974            #         scale=1.5,
 975            #         h_attach=h_attach,
 976            #         v_attach=v_attach,
 977            #         h_align=Text.HAlign.CENTER,
 978            #         v_align=Text.VAlign.CENTER,
 979            #         color=(1, 1, 1, 0.2 if hmo else 0.4),
 980            #         transition_delay=delay + 0.05,
 981            #         transition_out_delay=out_delay_fin,
 982            #     ).autoretain()
 983            # )
 984            # objs.append(
 985            #     Text(
 986            #         '+' + str(self.get_award_ticket_value()),
 987            #         host_only=True,
 988            #         position=(x + award_x + 28, y + 16),
 989            #         transition=Text.Transition.FADE_IN,
 990            #         scale=0.7,
 991            #         flatness=1,
 992            #         h_attach=h_attach,
 993            #         v_attach=v_attach,
 994            #         h_align=Text.HAlign.CENTER,
 995            #         v_align=Text.VAlign.CENTER,
 996            #         color=cl2,
 997            #         transition_delay=delay + 0.05,
 998            #         transition_out_delay=out_delay_fin,
 999            #     ).autoretain()
1000            # )
1001
1002        else:
1003            complete = self.complete
1004            objs = []
1005            c_icon = self.get_icon_color(complete)
1006            if hmo and not complete:
1007                c_icon = (c_icon[0], c_icon[1], c_icon[2], c_icon[3] * 0.3)
1008            objs.append(
1009                Image(
1010                    self.get_icon_texture(complete),
1011                    host_only=True,
1012                    color=c_icon,
1013                    position=(x - 25, y + 5),
1014                    attach=attach,
1015                    vr_depth=4,
1016                    transition=Image.Transition.IN_RIGHT,
1017                    transition_delay=delay,
1018                    transition_out_delay=None,
1019                    scale=(40, 40),
1020                ).autoretain()
1021            )
1022            if complete:
1023                objs.append(
1024                    Image(
1025                        bascenev1.gettexture('achievementOutline'),
1026                        host_only=True,
1027                        mesh_transparent=bascenev1.getmesh(
1028                            'achievementOutline'
1029                        ),
1030                        color=(2, 1.4, 0.4, 1),
1031                        vr_depth=8,
1032                        position=(x - 25, y + 5),
1033                        attach=attach,
1034                        transition=Image.Transition.IN_RIGHT,
1035                        transition_delay=delay,
1036                        transition_out_delay=None,
1037                        scale=(40, 40),
1038                    ).autoretain()
1039                )
1040            else:
1041                if not complete:
1042                    award_x = -100
1043                    # objs.append(
1044                    #     Text(
1045                    #         babase.charstr(babase.SpecialChar.TICKET),
1046                    #         host_only=True,
1047                    #         position=(x + award_x + 33, y + 7),
1048                    #         transition=Text.Transition.IN_RIGHT,
1049                    #         scale=1.5,
1050                    #         h_attach=h_attach,
1051                    #         v_attach=v_attach,
1052                    #         h_align=Text.HAlign.CENTER,
1053                    #         v_align=Text.VAlign.CENTER,
1054                    #         color=(1, 1, 1, (0.1 if hmo else 0.2)),
1055                    #         transition_delay=delay + 0.05,
1056                    #         transition_out_delay=None,
1057                    #     ).autoretain()
1058                    # )
1059                    chesttype = self.get_award_chest_type()
1060                    chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get(
1061                        chesttype, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT
1062                    )
1063                    objs.append(
1064                        Image(
1065                            # Provide magical extended dict version of texture
1066                            # that Image actor supports.
1067                            texture={
1068                                'texture': bascenev1.gettexture(
1069                                    chestdisplayinfo.texclosed
1070                                ),
1071                                'tint_texture': bascenev1.gettexture(
1072                                    chestdisplayinfo.texclosedtint
1073                                ),
1074                                'tint_color': chestdisplayinfo.tint,
1075                                'tint2_color': chestdisplayinfo.tint2,
1076                                'mask_texture': None,
1077                            },
1078                            color=chestdisplayinfo.color
1079                            + (0.5 if hmo else 1.0,),
1080                            position=(x + award_x + 38, y + 14),
1081                            scale=(32.0, 32.0),
1082                            transition=Image.Transition.IN_RIGHT,
1083                            transition_delay=delay + 0.05,
1084                            transition_out_delay=None,
1085                            host_only=True,
1086                            attach=attach,
1087                        ).autoretain()
1088                    )
1089
1090                    # objs.append(
1091                    #     Text(
1092                    #         '+' + str(self.get_award_ticket_value()),
1093                    #         host_only=True,
1094                    #         position=(x + award_x + 28, y + 16),
1095                    #         transition=Text.Transition.IN_RIGHT,
1096                    #         scale=0.7,
1097                    #         flatness=1,
1098                    #         h_attach=h_attach,
1099                    #         v_attach=v_attach,
1100                    #         h_align=Text.HAlign.CENTER,
1101                    #         v_align=Text.VAlign.CENTER,
1102                    #         color=(0.6, 0.6, 0.6, (0.2 if hmo else 0.4)),
1103                    #         transition_delay=delay + 0.05,
1104                    #         transition_out_delay=None,
1105                    #     ).autoretain()
1106                    # )
1107
1108                    # Show 'hard-mode-only' only over incomplete achievements
1109                    # when that's the case.
1110                    if hmo:
1111                        txtactor = Text(
1112                            babase.Lstr(resource='difficultyHardOnlyText'),
1113                            host_only=True,
1114                            maxwidth=300 * 0.7,
1115                            position=(x + 60, y + 5),
1116                            transition=Text.Transition.FADE_IN,
1117                            vr_depth=-5,
1118                            h_attach=h_attach,
1119                            v_attach=v_attach,
1120                            h_align=Text.HAlign.CENTER,
1121                            v_align=Text.VAlign.CENTER,
1122                            scale=0.85 * 0.8,
1123                            flatness=1.0,
1124                            shadow=0.5,
1125                            color=(1, 1, 0.6, 1),
1126                            transition_delay=delay + 0.05,
1127                            transition_out_delay=None,
1128                        ).autoretain()
1129                        assert txtactor.node
1130                        txtactor.node.rotate = 10
1131                        objs.append(txtactor)
1132
1133            objs.append(
1134                Text(
1135                    self.display_name,
1136                    host_only=True,
1137                    maxwidth=300,
1138                    position=(x, y + 2),
1139                    transition=Text.Transition.IN_RIGHT,
1140                    scale=0.85,
1141                    flatness=0.6,
1142                    h_attach=h_attach,
1143                    v_attach=v_attach,
1144                    color=(
1145                        (0.8, 0.93, 0.8, 1.0)
1146                        if complete
1147                        else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4))
1148                    ),
1149                    transition_delay=delay + 0.05,
1150                    transition_out_delay=None,
1151                ).autoretain()
1152            )
1153            objs.append(
1154                Text(
1155                    self.description_complete if complete else self.description,
1156                    host_only=True,
1157                    maxwidth=400,
1158                    position=(x, y - 14),
1159                    transition=Text.Transition.IN_RIGHT,
1160                    vr_depth=-5,
1161                    h_attach=h_attach,
1162                    v_attach=v_attach,
1163                    scale=0.62,
1164                    flatness=1.0,
1165                    color=(
1166                        (0.6, 0.6, 0.6, 1.0)
1167                        if complete
1168                        else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4))
1169                    ),
1170                    transition_delay=delay + 0.1,
1171                    transition_out_delay=None,
1172                ).autoretain()
1173            )
1174        return objs
1175
1176    def _getconfig(self) -> dict[str, Any]:
1177        """
1178        Return the sub-dict in settings where this achievement's
1179        state is stored, creating it if need be.
1180        """
1181        val: dict[str, Any] = babase.app.config.setdefault(
1182            'Achievements', {}
1183        ).setdefault(self._name, {'Complete': False})
1184        assert isinstance(val, dict)
1185        return val
1186
1187    def _remove_banner_slot(self) -> None:
1188        classic = babase.app.classic
1189        assert classic is not None
1190        assert self._completion_banner_slot is not None
1191        classic.ach.achievement_completion_banner_slots.remove(
1192            self._completion_banner_slot
1193        )
1194        self._completion_banner_slot = None
1195
1196    def show_completion_banner(self, sound: bool = True) -> None:
1197        """Create the banner/sound for an acquired achievement announcement."""
1198        from bascenev1lib.actor.text import Text
1199        from bascenev1lib.actor.image import Image
1200
1201        app = babase.app
1202        assert app.classic is not None
1203        app.classic.ach.last_achievement_display_time = babase.apptime()
1204
1205        # Just piggy-back onto any current activity
1206        # (should we use the session instead?..)
1207        activity = bascenev1.getactivity(doraise=False)
1208
1209        # If this gets called while this achievement is occupying a slot
1210        # already, ignore it. (probably should never happen in real
1211        # life but whatevs).
1212        if self._completion_banner_slot is not None:
1213            return
1214
1215        if activity is None:
1216            print('show_completion_banner() called with no current activity!')
1217            return
1218
1219        if sound:
1220            bascenev1.getsound('achievement').play(host_only=True)
1221        else:
1222            bascenev1.timer(
1223                0.5, lambda: bascenev1.getsound('ding').play(host_only=True)
1224            )
1225
1226        in_time = 0.300
1227        out_time = 3.5
1228
1229        base_vr_depth = 200
1230
1231        # Find the first free slot.
1232        i = 0
1233        while True:
1234            if i not in app.classic.ach.achievement_completion_banner_slots:
1235                app.classic.ach.achievement_completion_banner_slots.add(i)
1236                self._completion_banner_slot = i
1237
1238                # Remove us from that slot when we close.
1239                # Use an app-timer in an empty context so the removal
1240                # runs even if our activity/session dies.
1241                with babase.ContextRef.empty():
1242                    babase.apptimer(
1243                        in_time + out_time, self._remove_banner_slot
1244                    )
1245                break
1246            i += 1
1247        assert self._completion_banner_slot is not None
1248        y_offs = 110 * self._completion_banner_slot
1249        objs: list[bascenev1.Actor] = []
1250        obj = Image(
1251            bascenev1.gettexture('shadow'),
1252            position=(-30, 30 + y_offs),
1253            front=True,
1254            attach=Image.Attach.BOTTOM_CENTER,
1255            transition=Image.Transition.IN_BOTTOM,
1256            vr_depth=base_vr_depth - 100,
1257            transition_delay=in_time,
1258            transition_out_delay=out_time,
1259            color=(0.0, 0.1, 0, 1),
1260            scale=(1000, 300),
1261        ).autoretain()
1262        objs.append(obj)
1263        assert obj.node
1264        obj.node.host_only = True
1265        obj = Image(
1266            bascenev1.gettexture('light'),
1267            position=(-180, 60 + y_offs),
1268            front=True,
1269            attach=Image.Attach.BOTTOM_CENTER,
1270            vr_depth=base_vr_depth,
1271            transition=Image.Transition.IN_BOTTOM,
1272            transition_delay=in_time,
1273            transition_out_delay=out_time,
1274            color=(1.8, 1.8, 1.0, 0.0),
1275            scale=(40, 300),
1276        ).autoretain()
1277        objs.append(obj)
1278        assert obj.node
1279        obj.node.host_only = True
1280        obj.node.premultiplied = True
1281        combine = bascenev1.newnode(
1282            'combine', owner=obj.node, attrs={'size': 2}
1283        )
1284        bascenev1.animate(
1285            combine,
1286            'input0',
1287            {
1288                in_time: 0,
1289                in_time + 0.4: 30,
1290                in_time + 0.5: 40,
1291                in_time + 0.6: 30,
1292                in_time + 2.0: 0,
1293            },
1294        )
1295        bascenev1.animate(
1296            combine,
1297            'input1',
1298            {
1299                in_time: 0,
1300                in_time + 0.4: 200,
1301                in_time + 0.5: 500,
1302                in_time + 0.6: 200,
1303                in_time + 2.0: 0,
1304            },
1305        )
1306        combine.connectattr('output', obj.node, 'scale')
1307        bascenev1.animate(obj.node, 'rotate', {0: 0.0, 0.35: 360.0}, loop=True)
1308        obj = Image(
1309            self.get_icon_texture(True),
1310            position=(-180, 60 + y_offs),
1311            attach=Image.Attach.BOTTOM_CENTER,
1312            front=True,
1313            vr_depth=base_vr_depth - 10,
1314            transition=Image.Transition.IN_BOTTOM,
1315            transition_delay=in_time,
1316            transition_out_delay=out_time,
1317            scale=(100, 100),
1318        ).autoretain()
1319        objs.append(obj)
1320        assert obj.node
1321        obj.node.host_only = True
1322
1323        # Flash.
1324        color = self.get_icon_color(True)
1325        combine = bascenev1.newnode(
1326            'combine', owner=obj.node, attrs={'size': 3}
1327        )
1328        keys = {
1329            in_time: 1.0 * color[0],
1330            in_time + 0.4: 1.5 * color[0],
1331            in_time + 0.5: 6.0 * color[0],
1332            in_time + 0.6: 1.5 * color[0],
1333            in_time + 2.0: 1.0 * color[0],
1334        }
1335        bascenev1.animate(combine, 'input0', keys)
1336        keys = {
1337            in_time: 1.0 * color[1],
1338            in_time + 0.4: 1.5 * color[1],
1339            in_time + 0.5: 6.0 * color[1],
1340            in_time + 0.6: 1.5 * color[1],
1341            in_time + 2.0: 1.0 * color[1],
1342        }
1343        bascenev1.animate(combine, 'input1', keys)
1344        keys = {
1345            in_time: 1.0 * color[2],
1346            in_time + 0.4: 1.5 * color[2],
1347            in_time + 0.5: 6.0 * color[2],
1348            in_time + 0.6: 1.5 * color[2],
1349            in_time + 2.0: 1.0 * color[2],
1350        }
1351        bascenev1.animate(combine, 'input2', keys)
1352        combine.connectattr('output', obj.node, 'color')
1353
1354        obj = Image(
1355            bascenev1.gettexture('achievementOutline'),
1356            mesh_transparent=bascenev1.getmesh('achievementOutline'),
1357            position=(-180, 60 + y_offs),
1358            front=True,
1359            attach=Image.Attach.BOTTOM_CENTER,
1360            vr_depth=base_vr_depth,
1361            transition=Image.Transition.IN_BOTTOM,
1362            transition_delay=in_time,
1363            transition_out_delay=out_time,
1364            scale=(100, 100),
1365        ).autoretain()
1366        assert obj.node
1367        obj.node.host_only = True
1368
1369        # Flash.
1370        color = (2, 1.4, 0.4, 1)
1371        combine = bascenev1.newnode(
1372            'combine', owner=obj.node, attrs={'size': 3}
1373        )
1374        keys = {
1375            in_time: 1.0 * color[0],
1376            in_time + 0.4: 1.5 * color[0],
1377            in_time + 0.5: 6.0 * color[0],
1378            in_time + 0.6: 1.5 * color[0],
1379            in_time + 2.0: 1.0 * color[0],
1380        }
1381        bascenev1.animate(combine, 'input0', keys)
1382        keys = {
1383            in_time: 1.0 * color[1],
1384            in_time + 0.4: 1.5 * color[1],
1385            in_time + 0.5: 6.0 * color[1],
1386            in_time + 0.6: 1.5 * color[1],
1387            in_time + 2.0: 1.0 * color[1],
1388        }
1389        bascenev1.animate(combine, 'input1', keys)
1390        keys = {
1391            in_time: 1.0 * color[2],
1392            in_time + 0.4: 1.5 * color[2],
1393            in_time + 0.5: 6.0 * color[2],
1394            in_time + 0.6: 1.5 * color[2],
1395            in_time + 2.0: 1.0 * color[2],
1396        }
1397        bascenev1.animate(combine, 'input2', keys)
1398        combine.connectattr('output', obj.node, 'color')
1399        objs.append(obj)
1400
1401        objt = Text(
1402            babase.Lstr(
1403                value='${A}:',
1404                subs=[('${A}', babase.Lstr(resource='achievementText'))],
1405            ),
1406            position=(-120, 91 + y_offs),
1407            front=True,
1408            v_attach=Text.VAttach.BOTTOM,
1409            vr_depth=base_vr_depth - 10,
1410            transition=Text.Transition.IN_BOTTOM,
1411            flatness=0.5,
1412            transition_delay=in_time,
1413            transition_out_delay=out_time,
1414            color=(1, 1, 1, 0.8),
1415            scale=0.65,
1416        ).autoretain()
1417        objs.append(objt)
1418        assert objt.node
1419        objt.node.host_only = True
1420
1421        objt = Text(
1422            self.display_name,
1423            position=(-120, 50 + y_offs),
1424            front=True,
1425            v_attach=Text.VAttach.BOTTOM,
1426            transition=Text.Transition.IN_BOTTOM,
1427            vr_depth=base_vr_depth,
1428            flatness=0.5,
1429            transition_delay=in_time,
1430            transition_out_delay=out_time,
1431            flash=True,
1432            color=(1, 0.8, 0, 1.0),
1433            scale=1.5,
1434        ).autoretain()
1435        objs.append(objt)
1436        assert objt.node
1437        objt.node.host_only = True
1438
1439        # objt = Text(
1440        #     babase.charstr(babase.SpecialChar.TICKET),
1441        #     position=(-120 - 170 + 5, 75 + y_offs - 20),
1442        #     front=True,
1443        #     v_attach=Text.VAttach.BOTTOM,
1444        #     h_align=Text.HAlign.CENTER,
1445        #     v_align=Text.VAlign.CENTER,
1446        #     transition=Text.Transition.IN_BOTTOM,
1447        #     vr_depth=base_vr_depth,
1448        #     transition_delay=in_time,
1449        #     transition_out_delay=out_time,
1450        #     flash=True,
1451        #     color=(0.5, 0.5, 0.5, 1),
1452        #     scale=3.0,
1453        # ).autoretain()
1454        # objs.append(objt)
1455        # assert objt.node
1456        # objt.node.host_only = True
1457
1458        # print('FIXME SHOW ACH CHEST3')
1459        # objt = Text(
1460        #     '+' + str(self.get_award_ticket_value()),
1461        #     position=(-120 - 180 + 5, 80 + y_offs - 20),
1462        #     v_attach=Text.VAttach.BOTTOM,
1463        #     front=True,
1464        #     h_align=Text.HAlign.CENTER,
1465        #     v_align=Text.VAlign.CENTER,
1466        #     transition=Text.Transition.IN_BOTTOM,
1467        #     vr_depth=base_vr_depth,
1468        #     flatness=0.5,
1469        #     shadow=1.0,
1470        #     transition_delay=in_time,
1471        #     transition_out_delay=out_time,
1472        #     flash=True,
1473        #     color=(0, 1, 0, 1),
1474        #     scale=1.5,
1475        # ).autoretain()
1476        # objs.append(objt)
1477
1478        assert objt.node
1479        objt.node.host_only = True
1480
1481        # Add the 'x 2' if we've got pro.
1482        # if app.classic.accounts.have_pro():
1483        #     objt = Text(
1484        #         'x 2',
1485        #         position=(-120 - 180 + 45, 80 + y_offs - 50),
1486        #         v_attach=Text.VAttach.BOTTOM,
1487        #         front=True,
1488        #         h_align=Text.HAlign.CENTER,
1489        #         v_align=Text.VAlign.CENTER,
1490        #         transition=Text.Transition.IN_BOTTOM,
1491        #         vr_depth=base_vr_depth,
1492        #         flatness=0.5,
1493        #         shadow=1.0,
1494        #         transition_delay=in_time,
1495        #         transition_out_delay=out_time,
1496        #         flash=True,
1497        #         color=(0.4, 0, 1, 1),
1498        #         scale=0.9,
1499        #     ).autoretain()
1500        #     objs.append(objt)
1501        #     assert objt.node
1502        #     objt.node.host_only = True
1503
1504        objt = Text(
1505            self.description_complete,
1506            position=(-120, 30 + y_offs),
1507            front=True,
1508            v_attach=Text.VAttach.BOTTOM,
1509            transition=Text.Transition.IN_BOTTOM,
1510            vr_depth=base_vr_depth - 10,
1511            flatness=0.5,
1512            transition_delay=in_time,
1513            transition_out_delay=out_time,
1514            color=(1.0, 0.7, 0.5, 1.0),
1515            scale=0.8,
1516        ).autoretain()
1517        objs.append(objt)
1518        assert objt.node
1519        objt.node.host_only = True
1520
1521        for actor in objs:
1522            bascenev1.timer(
1523                out_time + 1.000,
1524                babase.WeakCall(actor.handlemessage, bascenev1.DieMessage()),
1525            )

Represents attributes and state for an individual achievement.

Achievement( name: str, icon_name: str, icon_color: tuple[float, float, float], level_name: str, *, award: int, hard_mode_only: bool = False)
534    def __init__(
535        self,
536        name: str,
537        icon_name: str,
538        icon_color: tuple[float, float, float],
539        level_name: str,
540        *,
541        award: int,
542        hard_mode_only: bool = False,
543    ):
544        self._name = name
545        self._icon_name = icon_name
546        assert len(icon_color) == 3
547        self._icon_color = icon_color + (1.0,)
548        self._level_name = level_name
549        self._completion_banner_slot: int | None = None
550        self._award = award
551        self._hard_mode_only = hard_mode_only
name: str
553    @property
554    def name(self) -> str:
555        """The name of this achievement."""
556        return self._name

The name of this achievement.

level_name: str
558    @property
559    def level_name(self) -> str:
560        """The name of the level this achievement applies to."""
561        return self._level_name

The name of the level this achievement applies to.

def get_icon_ui_texture(self, complete: bool) -> _bauiv1.Texture:
563    def get_icon_ui_texture(self, complete: bool) -> bauiv1.Texture:
564        """Return the icon texture to display for this achievement"""
565        return bauiv1.gettexture(
566            self._icon_name if complete else 'achievementEmpty'
567        )

Return the icon texture to display for this achievement

def get_icon_texture(self, complete: bool) -> _bascenev1.Texture:
569    def get_icon_texture(self, complete: bool) -> bascenev1.Texture:
570        """Return the icon texture to display for this achievement"""
571        return bascenev1.gettexture(
572            self._icon_name if complete else 'achievementEmpty'
573        )

Return the icon texture to display for this achievement

def get_icon_color(self, complete: bool) -> Sequence[float]:
575    def get_icon_color(self, complete: bool) -> Sequence[float]:
576        """Return the color tint for this Achievement's icon."""
577        if complete:
578            return self._icon_color
579        return 1.0, 1.0, 1.0, 0.6

Return the color tint for this Achievement's icon.

hard_mode_only: bool
581    @property
582    def hard_mode_only(self) -> bool:
583        """Whether this Achievement is only unlockable in hard-mode."""
584        return self._hard_mode_only

Whether this Achievement is only unlockable in hard-mode.

complete: bool
586    @property
587    def complete(self) -> bool:
588        """Whether this Achievement is currently complete."""
589        val: bool = self._getconfig()['Complete']
590        assert isinstance(val, bool)
591        return val

Whether this Achievement is currently complete.

def announce_completion(self, sound: bool = True) -> None:
593    def announce_completion(self, sound: bool = True) -> None:
594        """Kick off an announcement for this achievement's completion."""
595
596        app = babase.app
597        plus = app.plus
598        classic = app.classic
599        if plus is None or classic is None:
600            logging.warning('ach account_completion not available.')
601            return
602
603        ach_ss = classic.ach
604
605        # Even though there are technically achievements when we're not
606        # signed in, lets not show them (otherwise we tend to get
607        # confusing 'controller connected' achievements popping up while
608        # waiting to sign in which can be confusing).
609        if plus.get_v1_account_state() != 'signed_in':
610            return
611
612        # If we're being freshly complete, display/report it and whatnot.
613        if (self, sound) not in ach_ss.achievements_to_display:
614            ach_ss.achievements_to_display.append((self, sound))
615
616        # If there's no achievement display timer going, kick one off
617        # (if one's already running it will pick this up before it dies).
618
619        # Need to check last time too; its possible our timer wasn't able to
620        # clear itself if an activity died and took it down with it.
621        if (
622            ach_ss.achievement_display_timer is None
623            or babase.apptime() - ach_ss.last_achievement_display_time > 2.0
624        ) and bascenev1.getactivity(doraise=False) is not None:
625            ach_ss.achievement_display_timer = bascenev1.BaseTimer(
626                1.0, _display_next_achievement, repeat=True
627            )
628
629            # Show the first immediately.
630            _display_next_achievement()

Kick off an announcement for this achievement's completion.

def set_complete(self, complete: bool = True) -> None:
632    def set_complete(self, complete: bool = True) -> None:
633        """Set an achievement's completed state.
634
635        note this only sets local state; use a transaction to
636        actually award achievements.
637        """
638        config = self._getconfig()
639        if complete != config['Complete']:
640            config['Complete'] = complete

Set an achievement's completed state.

note this only sets local state; use a transaction to actually award achievements.

display_name: babase.Lstr
642    @property
643    def display_name(self) -> babase.Lstr:
644        """Return a babase.Lstr for this Achievement's name."""
645        name: babase.Lstr | str
646        try:
647            if self._level_name != '':
648                campaignname, campaign_level = self._level_name.split(':')
649                classic = babase.app.classic
650                assert classic is not None
651                name = (
652                    classic.getcampaign(campaignname)
653                    .getlevel(campaign_level)
654                    .displayname
655                )
656            else:
657                name = ''
658        except Exception:
659            name = ''
660            logging.exception('Error calcing achievement display-name.')
661        return babase.Lstr(
662            resource='achievements.' + self._name + '.name',
663            subs=[('${LEVEL}', name)],
664        )

Return a babase.Lstr for this Achievement's name.

description: babase.Lstr
666    @property
667    def description(self) -> babase.Lstr:
668        """Get a babase.Lstr for the Achievement's brief description."""
669        if (
670            'description'
671            in babase.app.lang.get_resource('achievements')[self._name]
672        ):
673            return babase.Lstr(
674                resource='achievements.' + self._name + '.description'
675            )
676        return babase.Lstr(
677            resource='achievements.' + self._name + '.descriptionFull'
678        )

Get a babase.Lstr for the Achievement's brief description.

description_complete: babase.Lstr
680    @property
681    def description_complete(self) -> babase.Lstr:
682        """Get a babase.Lstr for the Achievement's description when complete."""
683        if (
684            'descriptionComplete'
685            in babase.app.lang.get_resource('achievements')[self._name]
686        ):
687            return babase.Lstr(
688                resource='achievements.' + self._name + '.descriptionComplete'
689            )
690        return babase.Lstr(
691            resource='achievements.' + self._name + '.descriptionFullComplete'
692        )

Get a babase.Lstr for the Achievement's description when complete.

description_full: babase.Lstr
694    @property
695    def description_full(self) -> babase.Lstr:
696        """Get a babase.Lstr for the Achievement's full description."""
697        return babase.Lstr(
698            resource='achievements.' + self._name + '.descriptionFull',
699            subs=[
700                (
701                    '${LEVEL}',
702                    babase.Lstr(
703                        translate=(
704                            'coopLevelNames',
705                            ACH_LEVEL_NAMES.get(self._name, '?'),
706                        )
707                    ),
708                )
709            ],
710        )

Get a babase.Lstr for the Achievement's full description.

description_full_complete: babase.Lstr
712    @property
713    def description_full_complete(self) -> babase.Lstr:
714        """Get a babase.Lstr for the Achievement's full desc. when completed."""
715        return babase.Lstr(
716            resource='achievements.' + self._name + '.descriptionFullComplete',
717            subs=[
718                (
719                    '${LEVEL}',
720                    babase.Lstr(
721                        translate=(
722                            'coopLevelNames',
723                            ACH_LEVEL_NAMES.get(self._name, '?'),
724                        )
725                    ),
726                )
727            ],
728        )

Get a babase.Lstr for the Achievement's full desc. when completed.

def get_award_chest_type(self) -> bacommon.bs.ClassicChestAppearance:
730    def get_award_chest_type(self) -> ClassicChestAppearance:
731        """Return the type of chest given for this achievement."""
732
733        # For now just map our old ticket values to chest types.
734        # Can add distinct values if need be later.
735        plus = babase.app.plus
736        assert plus is not None
737        t = plus.get_v1_account_misc_read_val(
738            f'achAward.{self.name}', self._award
739        )
740        return (
741            ClassicChestAppearance.L6
742            if t >= 30
743            else (
744                ClassicChestAppearance.L5
745                if t >= 25
746                else (
747                    ClassicChestAppearance.L4
748                    if t >= 20
749                    else (
750                        ClassicChestAppearance.L3
751                        if t >= 15
752                        else (
753                            ClassicChestAppearance.L2
754                            if t >= 10
755                            else ClassicChestAppearance.L1
756                        )
757                    )
758                )
759            )
760        )

Return the type of chest given for this achievement.

power_ranking_value: int
773    @property
774    def power_ranking_value(self) -> int:
775        """Get the power-ranking award value for this achievement."""
776        plus = babase.app.plus
777        if plus is None:
778            return 0
779        val: int = plus.get_v1_account_misc_read_val(
780            'achLeaguePoints.' + self._name, self._award
781        )
782        assert isinstance(val, int)
783        return val

Get the power-ranking award value for this achievement.

def create_display( self, x: float, y: float, delay: float, *, outdelay: float | None = None, color: Optional[Sequence[float]] = None, style: str = 'post_game') -> list[bascenev1.Actor]:
 785    def create_display(
 786        self,
 787        x: float,
 788        y: float,
 789        delay: float,
 790        *,
 791        outdelay: float | None = None,
 792        color: Sequence[float] | None = None,
 793        style: str = 'post_game',
 794    ) -> list[bascenev1.Actor]:
 795        """Create a display for the Achievement.
 796
 797        Shows the Achievement icon, name, and description.
 798        """
 799        # pylint: disable=cyclic-import
 800        from bascenev1 import CoopSession
 801        from bascenev1lib.actor.image import Image
 802        from bascenev1lib.actor.text import Text
 803
 804        # Yeah this needs cleaning up.
 805        if style == 'post_game':
 806            in_game_colors = False
 807            in_main_menu = False
 808            h_attach = Text.HAttach.CENTER
 809            v_attach = Text.VAttach.CENTER
 810            attach = Image.Attach.CENTER
 811        elif style == 'in_game':
 812            in_game_colors = True
 813            in_main_menu = False
 814            h_attach = Text.HAttach.LEFT
 815            v_attach = Text.VAttach.TOP
 816            attach = Image.Attach.TOP_LEFT
 817        elif style == 'news':
 818            in_game_colors = True
 819            in_main_menu = True
 820            h_attach = Text.HAttach.CENTER
 821            v_attach = Text.VAttach.TOP
 822            attach = Image.Attach.TOP_CENTER
 823        else:
 824            raise ValueError('invalid style "' + style + '"')
 825
 826        # Attempt to determine what campaign we're in
 827        # (so we know whether to show "hard mode only").
 828        if in_main_menu:
 829            hmo = False
 830        else:
 831            try:
 832                session = bascenev1.getsession()
 833                if isinstance(session, CoopSession):
 834                    campaign = session.campaign
 835                    assert campaign is not None
 836                    hmo = self._hard_mode_only and campaign.name == 'Easy'
 837                else:
 838                    hmo = False
 839            except Exception:
 840                logging.exception('Error determining campaign.')
 841                hmo = False
 842
 843        objs: list[bascenev1.Actor]
 844
 845        if in_game_colors:
 846            objs = []
 847            out_delay_fin = (delay + outdelay) if outdelay is not None else None
 848            if color is not None:
 849                cl1 = (2.0 * color[0], 2.0 * color[1], 2.0 * color[2], color[3])
 850                cl2 = color
 851            else:
 852                cl1 = (1.5, 1.5, 2, 1.0)
 853                cl2 = (0.8, 0.8, 1.0, 1.0)
 854
 855            if hmo:
 856                cl1 = (cl1[0], cl1[1], cl1[2], cl1[3] * 0.6)
 857                cl2 = (cl2[0], cl2[1], cl2[2], cl2[3] * 0.2)
 858
 859            objs.append(
 860                Image(
 861                    self.get_icon_texture(False),
 862                    host_only=True,
 863                    color=cl1,
 864                    position=(x - 25, y + 5),
 865                    attach=attach,
 866                    transition=Image.Transition.FADE_IN,
 867                    transition_delay=delay,
 868                    vr_depth=4,
 869                    transition_out_delay=out_delay_fin,
 870                    scale=(40, 40),
 871                ).autoretain()
 872            )
 873            txt = self.display_name
 874            txt_s = 0.85
 875            txt_max_w = 300
 876            objs.append(
 877                Text(
 878                    txt,
 879                    host_only=True,
 880                    maxwidth=txt_max_w,
 881                    position=(x, y + 2),
 882                    transition=Text.Transition.FADE_IN,
 883                    scale=txt_s,
 884                    flatness=0.6,
 885                    shadow=0.5,
 886                    h_attach=h_attach,
 887                    v_attach=v_attach,
 888                    color=cl2,
 889                    transition_delay=delay + 0.05,
 890                    transition_out_delay=out_delay_fin,
 891                ).autoretain()
 892            )
 893            txt2_s = 0.62
 894            txt2_max_w = 400
 895            objs.append(
 896                Text(
 897                    self.description_full if in_main_menu else self.description,
 898                    host_only=True,
 899                    maxwidth=txt2_max_w,
 900                    position=(x, y - 14),
 901                    transition=Text.Transition.FADE_IN,
 902                    vr_depth=-5,
 903                    h_attach=h_attach,
 904                    v_attach=v_attach,
 905                    scale=txt2_s,
 906                    flatness=1.0,
 907                    shadow=0.5,
 908                    color=cl2,
 909                    transition_delay=delay + 0.1,
 910                    transition_out_delay=out_delay_fin,
 911                ).autoretain()
 912            )
 913
 914            if hmo:
 915                txtactor = Text(
 916                    babase.Lstr(resource='difficultyHardOnlyText'),
 917                    host_only=True,
 918                    maxwidth=txt2_max_w * 0.7,
 919                    position=(x + 60, y + 5),
 920                    transition=Text.Transition.FADE_IN,
 921                    vr_depth=-5,
 922                    h_attach=h_attach,
 923                    v_attach=v_attach,
 924                    h_align=Text.HAlign.CENTER,
 925                    v_align=Text.VAlign.CENTER,
 926                    scale=txt_s * 0.8,
 927                    flatness=1.0,
 928                    shadow=0.5,
 929                    color=(1, 1, 0.6, 1),
 930                    transition_delay=delay + 0.1,
 931                    transition_out_delay=out_delay_fin,
 932                ).autoretain()
 933                txtactor.node.rotate = 10
 934                objs.append(txtactor)
 935
 936            # Chest award.
 937            award_x = -100
 938            chesttype = self.get_award_chest_type()
 939            chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get(
 940                chesttype, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT
 941            )
 942            objs.append(
 943                Image(
 944                    # Provide magical extended dict version of texture
 945                    # that Image actor supports.
 946                    texture={
 947                        'texture': bascenev1.gettexture(
 948                            chestdisplayinfo.texclosed
 949                        ),
 950                        'tint_texture': bascenev1.gettexture(
 951                            chestdisplayinfo.texclosedtint
 952                        ),
 953                        'tint_color': chestdisplayinfo.tint,
 954                        'tint2_color': chestdisplayinfo.tint2,
 955                        'mask_texture': None,
 956                    },
 957                    color=chestdisplayinfo.color + (0.5 if hmo else 1.0,),
 958                    position=(x + award_x + 37, y + 12),
 959                    scale=(32.0, 32.0),
 960                    transition=Image.Transition.FADE_IN,
 961                    transition_delay=delay + 0.05,
 962                    transition_out_delay=out_delay_fin,
 963                    host_only=True,
 964                    attach=Image.Attach.TOP_LEFT,
 965                ).autoretain()
 966            )
 967
 968            # objs.append(
 969            #     Text(
 970            #         babase.charstr(babase.SpecialChar.TICKET),
 971            #         host_only=True,
 972            #         position=(x + award_x + 33, y + 7),
 973            #         transition=Text.Transition.FADE_IN,
 974            #         scale=1.5,
 975            #         h_attach=h_attach,
 976            #         v_attach=v_attach,
 977            #         h_align=Text.HAlign.CENTER,
 978            #         v_align=Text.VAlign.CENTER,
 979            #         color=(1, 1, 1, 0.2 if hmo else 0.4),
 980            #         transition_delay=delay + 0.05,
 981            #         transition_out_delay=out_delay_fin,
 982            #     ).autoretain()
 983            # )
 984            # objs.append(
 985            #     Text(
 986            #         '+' + str(self.get_award_ticket_value()),
 987            #         host_only=True,
 988            #         position=(x + award_x + 28, y + 16),
 989            #         transition=Text.Transition.FADE_IN,
 990            #         scale=0.7,
 991            #         flatness=1,
 992            #         h_attach=h_attach,
 993            #         v_attach=v_attach,
 994            #         h_align=Text.HAlign.CENTER,
 995            #         v_align=Text.VAlign.CENTER,
 996            #         color=cl2,
 997            #         transition_delay=delay + 0.05,
 998            #         transition_out_delay=out_delay_fin,
 999            #     ).autoretain()
1000            # )
1001
1002        else:
1003            complete = self.complete
1004            objs = []
1005            c_icon = self.get_icon_color(complete)
1006            if hmo and not complete:
1007                c_icon = (c_icon[0], c_icon[1], c_icon[2], c_icon[3] * 0.3)
1008            objs.append(
1009                Image(
1010                    self.get_icon_texture(complete),
1011                    host_only=True,
1012                    color=c_icon,
1013                    position=(x - 25, y + 5),
1014                    attach=attach,
1015                    vr_depth=4,
1016                    transition=Image.Transition.IN_RIGHT,
1017                    transition_delay=delay,
1018                    transition_out_delay=None,
1019                    scale=(40, 40),
1020                ).autoretain()
1021            )
1022            if complete:
1023                objs.append(
1024                    Image(
1025                        bascenev1.gettexture('achievementOutline'),
1026                        host_only=True,
1027                        mesh_transparent=bascenev1.getmesh(
1028                            'achievementOutline'
1029                        ),
1030                        color=(2, 1.4, 0.4, 1),
1031                        vr_depth=8,
1032                        position=(x - 25, y + 5),
1033                        attach=attach,
1034                        transition=Image.Transition.IN_RIGHT,
1035                        transition_delay=delay,
1036                        transition_out_delay=None,
1037                        scale=(40, 40),
1038                    ).autoretain()
1039                )
1040            else:
1041                if not complete:
1042                    award_x = -100
1043                    # objs.append(
1044                    #     Text(
1045                    #         babase.charstr(babase.SpecialChar.TICKET),
1046                    #         host_only=True,
1047                    #         position=(x + award_x + 33, y + 7),
1048                    #         transition=Text.Transition.IN_RIGHT,
1049                    #         scale=1.5,
1050                    #         h_attach=h_attach,
1051                    #         v_attach=v_attach,
1052                    #         h_align=Text.HAlign.CENTER,
1053                    #         v_align=Text.VAlign.CENTER,
1054                    #         color=(1, 1, 1, (0.1 if hmo else 0.2)),
1055                    #         transition_delay=delay + 0.05,
1056                    #         transition_out_delay=None,
1057                    #     ).autoretain()
1058                    # )
1059                    chesttype = self.get_award_chest_type()
1060                    chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get(
1061                        chesttype, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT
1062                    )
1063                    objs.append(
1064                        Image(
1065                            # Provide magical extended dict version of texture
1066                            # that Image actor supports.
1067                            texture={
1068                                'texture': bascenev1.gettexture(
1069                                    chestdisplayinfo.texclosed
1070                                ),
1071                                'tint_texture': bascenev1.gettexture(
1072                                    chestdisplayinfo.texclosedtint
1073                                ),
1074                                'tint_color': chestdisplayinfo.tint,
1075                                'tint2_color': chestdisplayinfo.tint2,
1076                                'mask_texture': None,
1077                            },
1078                            color=chestdisplayinfo.color
1079                            + (0.5 if hmo else 1.0,),
1080                            position=(x + award_x + 38, y + 14),
1081                            scale=(32.0, 32.0),
1082                            transition=Image.Transition.IN_RIGHT,
1083                            transition_delay=delay + 0.05,
1084                            transition_out_delay=None,
1085                            host_only=True,
1086                            attach=attach,
1087                        ).autoretain()
1088                    )
1089
1090                    # objs.append(
1091                    #     Text(
1092                    #         '+' + str(self.get_award_ticket_value()),
1093                    #         host_only=True,
1094                    #         position=(x + award_x + 28, y + 16),
1095                    #         transition=Text.Transition.IN_RIGHT,
1096                    #         scale=0.7,
1097                    #         flatness=1,
1098                    #         h_attach=h_attach,
1099                    #         v_attach=v_attach,
1100                    #         h_align=Text.HAlign.CENTER,
1101                    #         v_align=Text.VAlign.CENTER,
1102                    #         color=(0.6, 0.6, 0.6, (0.2 if hmo else 0.4)),
1103                    #         transition_delay=delay + 0.05,
1104                    #         transition_out_delay=None,
1105                    #     ).autoretain()
1106                    # )
1107
1108                    # Show 'hard-mode-only' only over incomplete achievements
1109                    # when that's the case.
1110                    if hmo:
1111                        txtactor = Text(
1112                            babase.Lstr(resource='difficultyHardOnlyText'),
1113                            host_only=True,
1114                            maxwidth=300 * 0.7,
1115                            position=(x + 60, y + 5),
1116                            transition=Text.Transition.FADE_IN,
1117                            vr_depth=-5,
1118                            h_attach=h_attach,
1119                            v_attach=v_attach,
1120                            h_align=Text.HAlign.CENTER,
1121                            v_align=Text.VAlign.CENTER,
1122                            scale=0.85 * 0.8,
1123                            flatness=1.0,
1124                            shadow=0.5,
1125                            color=(1, 1, 0.6, 1),
1126                            transition_delay=delay + 0.05,
1127                            transition_out_delay=None,
1128                        ).autoretain()
1129                        assert txtactor.node
1130                        txtactor.node.rotate = 10
1131                        objs.append(txtactor)
1132
1133            objs.append(
1134                Text(
1135                    self.display_name,
1136                    host_only=True,
1137                    maxwidth=300,
1138                    position=(x, y + 2),
1139                    transition=Text.Transition.IN_RIGHT,
1140                    scale=0.85,
1141                    flatness=0.6,
1142                    h_attach=h_attach,
1143                    v_attach=v_attach,
1144                    color=(
1145                        (0.8, 0.93, 0.8, 1.0)
1146                        if complete
1147                        else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4))
1148                    ),
1149                    transition_delay=delay + 0.05,
1150                    transition_out_delay=None,
1151                ).autoretain()
1152            )
1153            objs.append(
1154                Text(
1155                    self.description_complete if complete else self.description,
1156                    host_only=True,
1157                    maxwidth=400,
1158                    position=(x, y - 14),
1159                    transition=Text.Transition.IN_RIGHT,
1160                    vr_depth=-5,
1161                    h_attach=h_attach,
1162                    v_attach=v_attach,
1163                    scale=0.62,
1164                    flatness=1.0,
1165                    color=(
1166                        (0.6, 0.6, 0.6, 1.0)
1167                        if complete
1168                        else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4))
1169                    ),
1170                    transition_delay=delay + 0.1,
1171                    transition_out_delay=None,
1172                ).autoretain()
1173            )
1174        return objs

Create a display for the Achievement.

Shows the Achievement icon, name, and description.

def show_completion_banner(self, sound: bool = True) -> None:
1196    def show_completion_banner(self, sound: bool = True) -> None:
1197        """Create the banner/sound for an acquired achievement announcement."""
1198        from bascenev1lib.actor.text import Text
1199        from bascenev1lib.actor.image import Image
1200
1201        app = babase.app
1202        assert app.classic is not None
1203        app.classic.ach.last_achievement_display_time = babase.apptime()
1204
1205        # Just piggy-back onto any current activity
1206        # (should we use the session instead?..)
1207        activity = bascenev1.getactivity(doraise=False)
1208
1209        # If this gets called while this achievement is occupying a slot
1210        # already, ignore it. (probably should never happen in real
1211        # life but whatevs).
1212        if self._completion_banner_slot is not None:
1213            return
1214
1215        if activity is None:
1216            print('show_completion_banner() called with no current activity!')
1217            return
1218
1219        if sound:
1220            bascenev1.getsound('achievement').play(host_only=True)
1221        else:
1222            bascenev1.timer(
1223                0.5, lambda: bascenev1.getsound('ding').play(host_only=True)
1224            )
1225
1226        in_time = 0.300
1227        out_time = 3.5
1228
1229        base_vr_depth = 200
1230
1231        # Find the first free slot.
1232        i = 0
1233        while True:
1234            if i not in app.classic.ach.achievement_completion_banner_slots:
1235                app.classic.ach.achievement_completion_banner_slots.add(i)
1236                self._completion_banner_slot = i
1237
1238                # Remove us from that slot when we close.
1239                # Use an app-timer in an empty context so the removal
1240                # runs even if our activity/session dies.
1241                with babase.ContextRef.empty():
1242                    babase.apptimer(
1243                        in_time + out_time, self._remove_banner_slot
1244                    )
1245                break
1246            i += 1
1247        assert self._completion_banner_slot is not None
1248        y_offs = 110 * self._completion_banner_slot
1249        objs: list[bascenev1.Actor] = []
1250        obj = Image(
1251            bascenev1.gettexture('shadow'),
1252            position=(-30, 30 + y_offs),
1253            front=True,
1254            attach=Image.Attach.BOTTOM_CENTER,
1255            transition=Image.Transition.IN_BOTTOM,
1256            vr_depth=base_vr_depth - 100,
1257            transition_delay=in_time,
1258            transition_out_delay=out_time,
1259            color=(0.0, 0.1, 0, 1),
1260            scale=(1000, 300),
1261        ).autoretain()
1262        objs.append(obj)
1263        assert obj.node
1264        obj.node.host_only = True
1265        obj = Image(
1266            bascenev1.gettexture('light'),
1267            position=(-180, 60 + y_offs),
1268            front=True,
1269            attach=Image.Attach.BOTTOM_CENTER,
1270            vr_depth=base_vr_depth,
1271            transition=Image.Transition.IN_BOTTOM,
1272            transition_delay=in_time,
1273            transition_out_delay=out_time,
1274            color=(1.8, 1.8, 1.0, 0.0),
1275            scale=(40, 300),
1276        ).autoretain()
1277        objs.append(obj)
1278        assert obj.node
1279        obj.node.host_only = True
1280        obj.node.premultiplied = True
1281        combine = bascenev1.newnode(
1282            'combine', owner=obj.node, attrs={'size': 2}
1283        )
1284        bascenev1.animate(
1285            combine,
1286            'input0',
1287            {
1288                in_time: 0,
1289                in_time + 0.4: 30,
1290                in_time + 0.5: 40,
1291                in_time + 0.6: 30,
1292                in_time + 2.0: 0,
1293            },
1294        )
1295        bascenev1.animate(
1296            combine,
1297            'input1',
1298            {
1299                in_time: 0,
1300                in_time + 0.4: 200,
1301                in_time + 0.5: 500,
1302                in_time + 0.6: 200,
1303                in_time + 2.0: 0,
1304            },
1305        )
1306        combine.connectattr('output', obj.node, 'scale')
1307        bascenev1.animate(obj.node, 'rotate', {0: 0.0, 0.35: 360.0}, loop=True)
1308        obj = Image(
1309            self.get_icon_texture(True),
1310            position=(-180, 60 + y_offs),
1311            attach=Image.Attach.BOTTOM_CENTER,
1312            front=True,
1313            vr_depth=base_vr_depth - 10,
1314            transition=Image.Transition.IN_BOTTOM,
1315            transition_delay=in_time,
1316            transition_out_delay=out_time,
1317            scale=(100, 100),
1318        ).autoretain()
1319        objs.append(obj)
1320        assert obj.node
1321        obj.node.host_only = True
1322
1323        # Flash.
1324        color = self.get_icon_color(True)
1325        combine = bascenev1.newnode(
1326            'combine', owner=obj.node, attrs={'size': 3}
1327        )
1328        keys = {
1329            in_time: 1.0 * color[0],
1330            in_time + 0.4: 1.5 * color[0],
1331            in_time + 0.5: 6.0 * color[0],
1332            in_time + 0.6: 1.5 * color[0],
1333            in_time + 2.0: 1.0 * color[0],
1334        }
1335        bascenev1.animate(combine, 'input0', keys)
1336        keys = {
1337            in_time: 1.0 * color[1],
1338            in_time + 0.4: 1.5 * color[1],
1339            in_time + 0.5: 6.0 * color[1],
1340            in_time + 0.6: 1.5 * color[1],
1341            in_time + 2.0: 1.0 * color[1],
1342        }
1343        bascenev1.animate(combine, 'input1', keys)
1344        keys = {
1345            in_time: 1.0 * color[2],
1346            in_time + 0.4: 1.5 * color[2],
1347            in_time + 0.5: 6.0 * color[2],
1348            in_time + 0.6: 1.5 * color[2],
1349            in_time + 2.0: 1.0 * color[2],
1350        }
1351        bascenev1.animate(combine, 'input2', keys)
1352        combine.connectattr('output', obj.node, 'color')
1353
1354        obj = Image(
1355            bascenev1.gettexture('achievementOutline'),
1356            mesh_transparent=bascenev1.getmesh('achievementOutline'),
1357            position=(-180, 60 + y_offs),
1358            front=True,
1359            attach=Image.Attach.BOTTOM_CENTER,
1360            vr_depth=base_vr_depth,
1361            transition=Image.Transition.IN_BOTTOM,
1362            transition_delay=in_time,
1363            transition_out_delay=out_time,
1364            scale=(100, 100),
1365        ).autoretain()
1366        assert obj.node
1367        obj.node.host_only = True
1368
1369        # Flash.
1370        color = (2, 1.4, 0.4, 1)
1371        combine = bascenev1.newnode(
1372            'combine', owner=obj.node, attrs={'size': 3}
1373        )
1374        keys = {
1375            in_time: 1.0 * color[0],
1376            in_time + 0.4: 1.5 * color[0],
1377            in_time + 0.5: 6.0 * color[0],
1378            in_time + 0.6: 1.5 * color[0],
1379            in_time + 2.0: 1.0 * color[0],
1380        }
1381        bascenev1.animate(combine, 'input0', keys)
1382        keys = {
1383            in_time: 1.0 * color[1],
1384            in_time + 0.4: 1.5 * color[1],
1385            in_time + 0.5: 6.0 * color[1],
1386            in_time + 0.6: 1.5 * color[1],
1387            in_time + 2.0: 1.0 * color[1],
1388        }
1389        bascenev1.animate(combine, 'input1', keys)
1390        keys = {
1391            in_time: 1.0 * color[2],
1392            in_time + 0.4: 1.5 * color[2],
1393            in_time + 0.5: 6.0 * color[2],
1394            in_time + 0.6: 1.5 * color[2],
1395            in_time + 2.0: 1.0 * color[2],
1396        }
1397        bascenev1.animate(combine, 'input2', keys)
1398        combine.connectattr('output', obj.node, 'color')
1399        objs.append(obj)
1400
1401        objt = Text(
1402            babase.Lstr(
1403                value='${A}:',
1404                subs=[('${A}', babase.Lstr(resource='achievementText'))],
1405            ),
1406            position=(-120, 91 + y_offs),
1407            front=True,
1408            v_attach=Text.VAttach.BOTTOM,
1409            vr_depth=base_vr_depth - 10,
1410            transition=Text.Transition.IN_BOTTOM,
1411            flatness=0.5,
1412            transition_delay=in_time,
1413            transition_out_delay=out_time,
1414            color=(1, 1, 1, 0.8),
1415            scale=0.65,
1416        ).autoretain()
1417        objs.append(objt)
1418        assert objt.node
1419        objt.node.host_only = True
1420
1421        objt = Text(
1422            self.display_name,
1423            position=(-120, 50 + y_offs),
1424            front=True,
1425            v_attach=Text.VAttach.BOTTOM,
1426            transition=Text.Transition.IN_BOTTOM,
1427            vr_depth=base_vr_depth,
1428            flatness=0.5,
1429            transition_delay=in_time,
1430            transition_out_delay=out_time,
1431            flash=True,
1432            color=(1, 0.8, 0, 1.0),
1433            scale=1.5,
1434        ).autoretain()
1435        objs.append(objt)
1436        assert objt.node
1437        objt.node.host_only = True
1438
1439        # objt = Text(
1440        #     babase.charstr(babase.SpecialChar.TICKET),
1441        #     position=(-120 - 170 + 5, 75 + y_offs - 20),
1442        #     front=True,
1443        #     v_attach=Text.VAttach.BOTTOM,
1444        #     h_align=Text.HAlign.CENTER,
1445        #     v_align=Text.VAlign.CENTER,
1446        #     transition=Text.Transition.IN_BOTTOM,
1447        #     vr_depth=base_vr_depth,
1448        #     transition_delay=in_time,
1449        #     transition_out_delay=out_time,
1450        #     flash=True,
1451        #     color=(0.5, 0.5, 0.5, 1),
1452        #     scale=3.0,
1453        # ).autoretain()
1454        # objs.append(objt)
1455        # assert objt.node
1456        # objt.node.host_only = True
1457
1458        # print('FIXME SHOW ACH CHEST3')
1459        # objt = Text(
1460        #     '+' + str(self.get_award_ticket_value()),
1461        #     position=(-120 - 180 + 5, 80 + y_offs - 20),
1462        #     v_attach=Text.VAttach.BOTTOM,
1463        #     front=True,
1464        #     h_align=Text.HAlign.CENTER,
1465        #     v_align=Text.VAlign.CENTER,
1466        #     transition=Text.Transition.IN_BOTTOM,
1467        #     vr_depth=base_vr_depth,
1468        #     flatness=0.5,
1469        #     shadow=1.0,
1470        #     transition_delay=in_time,
1471        #     transition_out_delay=out_time,
1472        #     flash=True,
1473        #     color=(0, 1, 0, 1),
1474        #     scale=1.5,
1475        # ).autoretain()
1476        # objs.append(objt)
1477
1478        assert objt.node
1479        objt.node.host_only = True
1480
1481        # Add the 'x 2' if we've got pro.
1482        # if app.classic.accounts.have_pro():
1483        #     objt = Text(
1484        #         'x 2',
1485        #         position=(-120 - 180 + 45, 80 + y_offs - 50),
1486        #         v_attach=Text.VAttach.BOTTOM,
1487        #         front=True,
1488        #         h_align=Text.HAlign.CENTER,
1489        #         v_align=Text.VAlign.CENTER,
1490        #         transition=Text.Transition.IN_BOTTOM,
1491        #         vr_depth=base_vr_depth,
1492        #         flatness=0.5,
1493        #         shadow=1.0,
1494        #         transition_delay=in_time,
1495        #         transition_out_delay=out_time,
1496        #         flash=True,
1497        #         color=(0.4, 0, 1, 1),
1498        #         scale=0.9,
1499        #     ).autoretain()
1500        #     objs.append(objt)
1501        #     assert objt.node
1502        #     objt.node.host_only = True
1503
1504        objt = Text(
1505            self.description_complete,
1506            position=(-120, 30 + y_offs),
1507            front=True,
1508            v_attach=Text.VAttach.BOTTOM,
1509            transition=Text.Transition.IN_BOTTOM,
1510            vr_depth=base_vr_depth - 10,
1511            flatness=0.5,
1512            transition_delay=in_time,
1513            transition_out_delay=out_time,
1514            color=(1.0, 0.7, 0.5, 1.0),
1515            scale=0.8,
1516        ).autoretain()
1517        objs.append(objt)
1518        assert objt.node
1519        objt.node.host_only = True
1520
1521        for actor in objs:
1522            bascenev1.timer(
1523                out_time + 1.000,
1524                babase.WeakCall(actor.handlemessage, bascenev1.DieMessage()),
1525            )

Create the banner/sound for an acquired achievement announcement.

class AchievementSubsystem:
 74class AchievementSubsystem:
 75    """Subsystem for achievement handling.
 76
 77    Access the single shared instance of this class at 'ba.app.ach'.
 78    """
 79
 80    def __init__(self) -> None:
 81        self.achievements: list[Achievement] = []
 82        self.achievements_to_display: list[
 83            tuple[baclassic.Achievement, bool]
 84        ] = []
 85        self.achievement_display_timer: bascenev1.BaseTimer | None = None
 86        self.last_achievement_display_time: float = 0.0
 87        self.achievement_completion_banner_slots: set[int] = set()
 88        self._init_achievements()
 89
 90    def _init_achievements(self) -> None:
 91        """Fill in available achievements."""
 92
 93        self.achievements += [
 94            Achievement(
 95                'In Control',
 96                'achievementInControl',
 97                (1, 1, 1),
 98                '',
 99                award=5,
100            ),
101            Achievement(
102                'Sharing is Caring',
103                'achievementSharingIsCaring',
104                (1, 1, 1),
105                '',
106                award=15,
107            ),
108            Achievement(
109                'Dual Wielding',
110                'achievementDualWielding',
111                (1, 1, 1),
112                '',
113                award=10,
114            ),
115            Achievement(
116                'Free Loader',
117                'achievementFreeLoader',
118                (1, 1, 1),
119                '',
120                award=10,
121            ),
122            Achievement(
123                'Team Player',
124                'achievementTeamPlayer',
125                (1, 1, 1),
126                '',
127                award=20,
128            ),
129            Achievement(
130                'Onslaught Training Victory',
131                'achievementOnslaught',
132                (1, 1, 1),
133                'Default:Onslaught Training',
134                award=5,
135            ),
136            Achievement(
137                'Off You Go Then',
138                'achievementOffYouGo',
139                (1, 1.1, 1.3),
140                'Default:Onslaught Training',
141                award=5,
142            ),
143            Achievement(
144                'Boxer',
145                'achievementBoxer',
146                (1, 0.6, 0.6),
147                'Default:Onslaught Training',
148                award=10,
149                hard_mode_only=True,
150            ),
151            Achievement(
152                'Rookie Onslaught Victory',
153                'achievementOnslaught',
154                (0.5, 1.4, 0.6),
155                'Default:Rookie Onslaught',
156                award=10,
157            ),
158            Achievement(
159                'Mine Games',
160                'achievementMine',
161                (1, 1, 1.4),
162                'Default:Rookie Onslaught',
163                award=10,
164            ),
165            Achievement(
166                'Flawless Victory',
167                'achievementFlawlessVictory',
168                (1, 1, 1),
169                'Default:Rookie Onslaught',
170                award=15,
171                hard_mode_only=True,
172            ),
173            Achievement(
174                'Rookie Football Victory',
175                'achievementFootballVictory',
176                (1.0, 1, 0.6),
177                'Default:Rookie Football',
178                award=10,
179            ),
180            Achievement(
181                'Super Punch',
182                'achievementSuperPunch',
183                (1, 1, 1.8),
184                'Default:Rookie Football',
185                award=10,
186            ),
187            Achievement(
188                'Rookie Football Shutout',
189                'achievementFootballShutout',
190                (1, 1, 1),
191                'Default:Rookie Football',
192                award=15,
193                hard_mode_only=True,
194            ),
195            Achievement(
196                'Pro Onslaught Victory',
197                'achievementOnslaught',
198                (0.3, 1, 2.0),
199                'Default:Pro Onslaught',
200                award=15,
201            ),
202            Achievement(
203                'Boom Goes the Dynamite',
204                'achievementTNT',
205                (1.4, 1.2, 0.8),
206                'Default:Pro Onslaught',
207                award=15,
208            ),
209            Achievement(
210                'Pro Boxer',
211                'achievementBoxer',
212                (2, 2, 0),
213                'Default:Pro Onslaught',
214                award=20,
215                hard_mode_only=True,
216            ),
217            Achievement(
218                'Pro Football Victory',
219                'achievementFootballVictory',
220                (1.3, 1.3, 2.0),
221                'Default:Pro Football',
222                award=15,
223            ),
224            Achievement(
225                'Super Mega Punch',
226                'achievementSuperPunch',
227                (2, 1, 0.6),
228                'Default:Pro Football',
229                award=15,
230            ),
231            Achievement(
232                'Pro Football Shutout',
233                'achievementFootballShutout',
234                (0.7, 0.7, 2.0),
235                'Default:Pro Football',
236                award=20,
237                hard_mode_only=True,
238            ),
239            Achievement(
240                'Pro Runaround Victory',
241                'achievementRunaround',
242                (1, 1, 1),
243                'Default:Pro Runaround',
244                award=15,
245            ),
246            Achievement(
247                'Precision Bombing',
248                'achievementCrossHair',
249                (1, 1, 1.3),
250                'Default:Pro Runaround',
251                award=20,
252                hard_mode_only=True,
253            ),
254            Achievement(
255                'The Wall',
256                'achievementWall',
257                (1, 0.7, 0.7),
258                'Default:Pro Runaround',
259                award=25,
260                hard_mode_only=True,
261            ),
262            Achievement(
263                'Uber Onslaught Victory',
264                'achievementOnslaught',
265                (2, 2, 1),
266                'Default:Uber Onslaught',
267                award=30,
268            ),
269            Achievement(
270                'Gold Miner',
271                'achievementMine',
272                (2, 1.6, 0.2),
273                'Default:Uber Onslaught',
274                award=30,
275                hard_mode_only=True,
276            ),
277            Achievement(
278                'TNT Terror',
279                'achievementTNT',
280                (2, 1.8, 0.3),
281                'Default:Uber Onslaught',
282                award=30,
283                hard_mode_only=True,
284            ),
285            Achievement(
286                'Uber Football Victory',
287                'achievementFootballVictory',
288                (1.8, 1.4, 0.3),
289                'Default:Uber Football',
290                award=30,
291            ),
292            Achievement(
293                'Got the Moves',
294                'achievementGotTheMoves',
295                (2, 1, 0),
296                'Default:Uber Football',
297                award=30,
298                hard_mode_only=True,
299            ),
300            Achievement(
301                'Uber Football Shutout',
302                'achievementFootballShutout',
303                (2, 2, 0),
304                'Default:Uber Football',
305                award=40,
306                hard_mode_only=True,
307            ),
308            Achievement(
309                'Uber Runaround Victory',
310                'achievementRunaround',
311                (1.5, 1.2, 0.2),
312                'Default:Uber Runaround',
313                award=30,
314            ),
315            Achievement(
316                'The Great Wall',
317                'achievementWall',
318                (2, 1.7, 0.4),
319                'Default:Uber Runaround',
320                award=40,
321                hard_mode_only=True,
322            ),
323            Achievement(
324                'Stayin\' Alive',
325                'achievementStayinAlive',
326                (2, 2, 1),
327                'Default:Uber Runaround',
328                award=40,
329                hard_mode_only=True,
330            ),
331            Achievement(
332                'Last Stand Master',
333                'achievementMedalSmall',
334                (2, 1.5, 0.3),
335                'Default:The Last Stand',
336                award=20,
337                hard_mode_only=True,
338            ),
339            Achievement(
340                'Last Stand Wizard',
341                'achievementMedalMedium',
342                (2, 1.5, 0.3),
343                'Default:The Last Stand',
344                award=40,
345                hard_mode_only=True,
346            ),
347            Achievement(
348                'Last Stand God',
349                'achievementMedalLarge',
350                (2, 1.5, 0.3),
351                'Default:The Last Stand',
352                award=60,
353                hard_mode_only=True,
354            ),
355            Achievement(
356                'Onslaught Master',
357                'achievementMedalSmall',
358                (0.7, 1, 0.7),
359                'Challenges:Infinite Onslaught',
360                award=5,
361            ),
362            Achievement(
363                'Onslaught Wizard',
364                'achievementMedalMedium',
365                (0.7, 1.0, 0.7),
366                'Challenges:Infinite Onslaught',
367                award=15,
368            ),
369            Achievement(
370                'Onslaught God',
371                'achievementMedalLarge',
372                (0.7, 1.0, 0.7),
373                'Challenges:Infinite Onslaught',
374                award=30,
375            ),
376            Achievement(
377                'Runaround Master',
378                'achievementMedalSmall',
379                (1.0, 1.0, 1.2),
380                'Challenges:Infinite Runaround',
381                award=5,
382            ),
383            Achievement(
384                'Runaround Wizard',
385                'achievementMedalMedium',
386                (1.0, 1.0, 1.2),
387                'Challenges:Infinite Runaround',
388                award=15,
389            ),
390            Achievement(
391                'Runaround God',
392                'achievementMedalLarge',
393                (1.0, 1.0, 1.2),
394                'Challenges:Infinite Runaround',
395                award=30,
396            ),
397        ]
398
399    def award_local_achievement(self, achname: str) -> None:
400        """For non-game-based achievements such as controller-connection."""
401        plus = babase.app.plus
402        if plus is None:
403            logging.warning('achievements require plus feature-set')
404            return
405        try:
406            ach = self.get_achievement(achname)
407            if not ach.complete:
408                # Report new achievements to the game-service.
409                plus.report_achievement(achname)
410
411                # And to our account.
412                plus.add_v1_account_transaction(
413                    {'type': 'ACHIEVEMENT', 'name': achname}
414                )
415
416                # Now attempt to show a banner.
417                self.display_achievement_banner(achname)
418
419        except Exception:
420            logging.exception('Error in award_local_achievement.')
421
422    def display_achievement_banner(self, achname: str) -> None:
423        """Display a completion banner for an achievement.
424
425        (internal)
426
427        Used for server-driven achievements.
428        """
429        try:
430            # FIXME: Need to get these using the UI context or some other
431            #  purely local context somehow instead of trying to inject these
432            #  into whatever activity happens to be active
433            #  (since that won't work while in client mode).
434            activity = bascenev1.get_foreground_host_activity()
435            if activity is not None:
436                with activity.context:
437                    self.get_achievement(achname).announce_completion()
438        except Exception:
439            logging.exception('Error in display_achievement_banner.')
440
441    def set_completed_achievements(self, achs: Sequence[str]) -> None:
442        """Set the current state of completed achievements.
443
444        (internal)
445
446        All achievements not included here will be set incomplete.
447        """
448
449        # Note: This gets called whenever game-center/game-circle/etc tells
450        # us which achievements we currently have.  We always defer to them,
451        # even if that means we have to un-set an achievement we think we have.
452
453        cfg = babase.app.config
454        cfg['Achievements'] = {}
455        for a_name in achs:
456            self.get_achievement(a_name).set_complete(True)
457        cfg.commit()
458
459    def get_achievement(self, name: str) -> Achievement:
460        """Return an Achievement by name."""
461        achs = [a for a in self.achievements if a.name == name]
462        assert len(achs) < 2
463        if not achs:
464            raise ValueError("Invalid achievement name: '" + name + "'")
465        return achs[0]
466
467    def achievements_for_coop_level(self, level_name: str) -> list[Achievement]:
468        """Given a level name, return achievements available for it."""
469
470        # For the Easy campaign we return achievements for the Default
471        # campaign too. (want the user to see what achievements are part of the
472        # level even if they can't unlock them all on easy mode).
473        return [
474            a
475            for a in self.achievements
476            if a.level_name
477            in (level_name, level_name.replace('Easy', 'Default'))
478        ]
479
480    def _test(self) -> None:
481        """For testing achievement animations."""
482
483        def testcall1() -> None:
484            self.achievements[0].announce_completion()
485            self.achievements[1].announce_completion()
486            self.achievements[2].announce_completion()
487
488        def testcall2() -> None:
489            self.achievements[3].announce_completion()
490            self.achievements[4].announce_completion()
491            self.achievements[5].announce_completion()
492
493        bascenev1.basetimer(3.0, testcall1)
494        bascenev1.basetimer(7.0, testcall2)

Subsystem for achievement handling.

Access the single shared instance of this class at 'ba.app.ach'.

achievements: list[Achievement]
achievements_to_display: list[tuple[Achievement, bool]]
achievement_display_timer: _bascenev1.BaseTimer | None
last_achievement_display_time: float
achievement_completion_banner_slots: set[int]
def award_local_achievement(self, achname: str) -> None:
399    def award_local_achievement(self, achname: str) -> None:
400        """For non-game-based achievements such as controller-connection."""
401        plus = babase.app.plus
402        if plus is None:
403            logging.warning('achievements require plus feature-set')
404            return
405        try:
406            ach = self.get_achievement(achname)
407            if not ach.complete:
408                # Report new achievements to the game-service.
409                plus.report_achievement(achname)
410
411                # And to our account.
412                plus.add_v1_account_transaction(
413                    {'type': 'ACHIEVEMENT', 'name': achname}
414                )
415
416                # Now attempt to show a banner.
417                self.display_achievement_banner(achname)
418
419        except Exception:
420            logging.exception('Error in award_local_achievement.')

For non-game-based achievements such as controller-connection.

def get_achievement(self, name: str) -> Achievement:
459    def get_achievement(self, name: str) -> Achievement:
460        """Return an Achievement by name."""
461        achs = [a for a in self.achievements if a.name == name]
462        assert len(achs) < 2
463        if not achs:
464            raise ValueError("Invalid achievement name: '" + name + "'")
465        return achs[0]

Return an Achievement by name.

def achievements_for_coop_level(self, level_name: str) -> list[Achievement]:
467    def achievements_for_coop_level(self, level_name: str) -> list[Achievement]:
468        """Given a level name, return achievements available for it."""
469
470        # For the Easy campaign we return achievements for the Default
471        # campaign too. (want the user to see what achievements are part of the
472        # level even if they can't unlock them all on easy mode).
473        return [
474            a
475            for a in self.achievements
476            if a.level_name
477            in (level_name, level_name.replace('Easy', 'Default'))
478        ]

Given a level name, return achievements available for it.

def show_display_item( itemwrapper: bacommon.bs.DisplayItemWrapper, parent: _bauiv1.Widget, pos: tuple[float, float], width: float) -> None:
 18def show_display_item(
 19    itemwrapper: bacommon.bs.DisplayItemWrapper,
 20    parent: bauiv1.Widget,
 21    pos: tuple[float, float],
 22    width: float,
 23) -> None:
 24    """Create ui to depict a display-item."""
 25
 26    height = width * 0.666
 27
 28    # Silent no-op if our parent ui is dead.
 29    if not parent:
 30        return
 31
 32    img: str | None = None
 33    img_y_offs = 0.0
 34    text_y_offs = 0.0
 35    show_text = True
 36
 37    if isinstance(itemwrapper.item, bacommon.bs.TicketsDisplayItem):
 38        img = 'tickets'
 39        img_y_offs = width * 0.11
 40        text_y_offs = width * -0.15
 41    elif isinstance(itemwrapper.item, bacommon.bs.TokensDisplayItem):
 42        img = 'coin'
 43        img_y_offs = width * 0.11
 44        text_y_offs = width * -0.15
 45    elif isinstance(itemwrapper.item, bacommon.bs.ChestDisplayItem):
 46        from baclassic._chest import (
 47            CHEST_APPEARANCE_DISPLAY_INFOS,
 48            CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT,
 49        )
 50
 51        img = None
 52        show_text = False
 53        c_info = CHEST_APPEARANCE_DISPLAY_INFOS.get(
 54            itemwrapper.item.appearance, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT
 55        )
 56        c_size = width * 0.85
 57        bauiv1.imagewidget(
 58            parent=parent,
 59            position=(pos[0] - c_size * 0.5, pos[1] - c_size * 0.5),
 60            color=c_info.color,
 61            size=(c_size, c_size),
 62            texture=bauiv1.gettexture(c_info.texclosed),
 63            tint_texture=bauiv1.gettexture(c_info.texclosedtint),
 64            tint_color=c_info.tint,
 65            tint2_color=c_info.tint2,
 66        )
 67
 68    # Enable this for testing spacing.
 69    if bool(False):
 70        bauiv1.imagewidget(
 71            parent=parent,
 72            position=(
 73                pos[0] - width * 0.5,
 74                pos[1] - height * 0.5,
 75            ),
 76            size=(width, height),
 77            texture=bauiv1.gettexture('white'),
 78            color=(0, 1, 0),
 79            opacity=0.1,
 80        )
 81
 82    imgsize = width * 0.33
 83    if img is not None:
 84        bauiv1.imagewidget(
 85            parent=parent,
 86            position=(
 87                pos[0] - imgsize * 0.5,
 88                pos[1] + img_y_offs - imgsize * 0.5,
 89            ),
 90            size=(imgsize, imgsize),
 91            texture=bauiv1.gettexture(img),
 92        )
 93    if show_text:
 94        subs = itemwrapper.description_subs
 95        if subs is None:
 96            subs = []
 97        bauiv1.textwidget(
 98            parent=parent,
 99            position=(pos[0], pos[1] + text_y_offs),
100            scale=width * 0.006,
101            size=(0, 0),
102            text=bauiv1.Lstr(
103                translate=('displayItemNames', itemwrapper.description),
104                subs=pairs_from_flat(subs),
105            ),
106            maxwidth=width * 0.9,
107            color=(0.0, 1.0, 0.0),
108            h_align='center',
109            v_align='center',
110        )

Create ui to depict a display-item.