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 scenev1 and uiv1. 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
 8scenev1 and uiv1. Future feature-sets will be designed in a more modular
 9way.
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):
 29class ClassicAppMode(babase.AppMode):
 30    """AppMode for the classic BombSquad experience."""
 31
 32    LEAGUE_VIS_VALS_CONFIG_KEY = 'ClassicLeagueVisVals'
 33
 34    def __init__(self) -> None:
 35        self._on_primary_account_changed_callback: (
 36            CallbackRegistration | None
 37        ) = None
 38        self._on_connectivity_changed_callback: CallbackRegistration | None = (
 39            None
 40        )
 41        self._test_sub: babase.CloudSubscription | None = None
 42        self._account_data_sub: babase.CloudSubscription | None = None
 43
 44        self._have_account_values = False
 45        self._have_connectivity = False
 46        self._current_account_id: str | None = None
 47        self._should_restore_account_league_vis_vals = False
 48
 49    @override
 50    @classmethod
 51    def get_app_experience(cls) -> AppExperience:
 52        return AppExperience.MELEE
 53
 54    @override
 55    @classmethod
 56    def _can_handle_intent(cls, intent: babase.AppIntent) -> bool:
 57        # We support default and exec intents currently.
 58        return isinstance(
 59            intent, babase.AppIntentExec | babase.AppIntentDefault
 60        )
 61
 62    @override
 63    def handle_intent(self, intent: babase.AppIntent) -> None:
 64        if isinstance(intent, babase.AppIntentExec):
 65            _baclassic.classic_app_mode_handle_app_intent_exec(intent.code)
 66            return
 67        assert isinstance(intent, babase.AppIntentDefault)
 68        _baclassic.classic_app_mode_handle_app_intent_default()
 69
 70    @override
 71    def on_activate(self) -> None:
 72
 73        # Let the native layer do its thing.
 74        _baclassic.classic_app_mode_activate()
 75
 76        app = babase.app
 77        plus = app.plus
 78        assert plus is not None
 79
 80        # Wire up the root ui to do what we want.
 81        ui = app.ui_v1
 82        ui.root_ui_calls[ui.RootUIElement.ACCOUNT_BUTTON] = (
 83            self._root_ui_account_press
 84        )
 85        ui.root_ui_calls[ui.RootUIElement.MENU_BUTTON] = (
 86            self._root_ui_menu_press
 87        )
 88        ui.root_ui_calls[ui.RootUIElement.SQUAD_BUTTON] = (
 89            self._root_ui_squad_press
 90        )
 91        ui.root_ui_calls[ui.RootUIElement.SETTINGS_BUTTON] = (
 92            self._root_ui_settings_press
 93        )
 94        ui.root_ui_calls[ui.RootUIElement.STORE_BUTTON] = (
 95            self._root_ui_store_press
 96        )
 97        ui.root_ui_calls[ui.RootUIElement.INVENTORY_BUTTON] = (
 98            self._root_ui_inventory_press
 99        )
100        ui.root_ui_calls[ui.RootUIElement.GET_TOKENS_BUTTON] = (
101            self._root_ui_get_tokens_press
102        )
103        ui.root_ui_calls[ui.RootUIElement.INBOX_BUTTON] = (
104            self._root_ui_inbox_press
105        )
106        ui.root_ui_calls[ui.RootUIElement.TICKETS_METER] = (
107            self._root_ui_tickets_meter_press
108        )
109        ui.root_ui_calls[ui.RootUIElement.TOKENS_METER] = (
110            self._root_ui_tokens_meter_press
111        )
112        ui.root_ui_calls[ui.RootUIElement.TROPHY_METER] = (
113            self._root_ui_trophy_meter_press
114        )
115        ui.root_ui_calls[ui.RootUIElement.LEVEL_METER] = (
116            self._root_ui_level_meter_press
117        )
118        ui.root_ui_calls[ui.RootUIElement.ACHIEVEMENTS_BUTTON] = (
119            self._root_ui_achievements_press
120        )
121        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_0] = partial(
122            self._root_ui_chest_slot_pressed, 0
123        )
124        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_1] = partial(
125            self._root_ui_chest_slot_pressed, 1
126        )
127        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_2] = partial(
128            self._root_ui_chest_slot_pressed, 2
129        )
130        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_3] = partial(
131            self._root_ui_chest_slot_pressed, 3
132        )
133
134        # We want to be informed when connectivity changes.
135        self._on_connectivity_changed_callback = (
136            plus.cloud.on_connectivity_changed_callbacks.register(
137                self._update_for_connectivity_change
138            )
139        )
140        # We want to be informed when primary account changes.
141        self._on_primary_account_changed_callback = (
142            plus.accounts.on_primary_account_changed_callbacks.register(
143                self._update_for_primary_account
144            )
145        )
146        # Establish subscriptions/etc. for any current primary account.
147        self._update_for_primary_account(plus.accounts.primary)
148        self._have_connectivity = plus.cloud.is_connected()
149        self._update_for_connectivity_change(self._have_connectivity)
150
151    @override
152    def on_deactivate(self) -> None:
153
154        classic = babase.app.classic
155
156        # Store latest league vis vals for any active account.
157        self._save_account_league_vis_vals()
158
159        # Stop being informed of account changes.
160        self._on_primary_account_changed_callback = None
161
162        # Remove anything following any current account.
163        self._update_for_primary_account(None)
164
165        # Save where we were in the UI so we return there next time.
166        if classic is not None:
167            classic.save_ui_state()
168
169        # Let the native layer do its thing.
170        _baclassic.classic_app_mode_deactivate()
171
172    @override
173    def on_app_active_changed(self) -> None:
174        if not babase.app.active:
175            # If we've gone inactive, bring up the main menu, which has the
176            # side effect of pausing the action (when possible).
177            babase.invoke_main_menu()
178
179            # Also store any league vis state for the active account.
180            # this may be our last chance to do this on mobile.
181            self._save_account_league_vis_vals()
182
183    def _update_for_primary_account(
184        self, account: babase.AccountV2Handle | None
185    ) -> None:
186        """Update subscriptions/etc. for a new primary account state."""
187        assert babase.in_logic_thread()
188        plus = babase.app.plus
189
190        assert plus is not None
191
192        classic = babase.app.classic
193        assert classic is not None
194
195        if account is not None:
196            self._current_account_id = account.accountid
197            babase.set_ui_account_state(True, account.tag)
198            self._should_restore_account_league_vis_vals = True
199        else:
200            # If we had an account, save any existing league vis state
201            # so we'll properly animate to new values the next time we
202            # sign in.
203            self._save_account_league_vis_vals()
204
205            self._current_account_id = None
206            babase.set_ui_account_state(False)
207            self._should_restore_account_league_vis_vals = False
208
209        # For testing subscription functionality.
210        if os.environ.get('BA_SUBSCRIPTION_TEST') == '1':
211            if account is None:
212                self._test_sub = None
213            else:
214                with account:
215                    self._test_sub = plus.cloud.subscribe_test(
216                        self._on_sub_test_update
217                    )
218        else:
219            self._test_sub = None
220
221        if account is None:
222            classic.gold_pass = False
223            classic.chest_dock_full = False
224            classic.remove_ads = False
225            self._account_data_sub = None
226            _baclassic.set_root_ui_account_values(
227                tickets=-1,
228                tokens=-1,
229                league_type='',
230                league_number=-1,
231                league_rank=-1,
232                achievements_percent_text='',
233                level_text='',
234                xp_text='',
235                inbox_count_text='',
236                gold_pass=False,
237                chest_0_appearance='',
238                chest_1_appearance='',
239                chest_2_appearance='',
240                chest_3_appearance='',
241                chest_0_unlock_time=-1.0,
242                chest_1_unlock_time=-1.0,
243                chest_2_unlock_time=-1.0,
244                chest_3_unlock_time=-1.0,
245                chest_0_ad_allow_time=-1.0,
246                chest_1_ad_allow_time=-1.0,
247                chest_2_ad_allow_time=-1.0,
248                chest_3_ad_allow_time=-1.0,
249            )
250            self._have_account_values = False
251            self._update_ui_live_state()
252
253        else:
254            with account:
255                self._account_data_sub = (
256                    plus.cloud.subscribe_classic_account_data(
257                        self._on_classic_account_data_change
258                    )
259                )
260
261    def _update_for_connectivity_change(self, connected: bool) -> None:
262        """Update when the app's connectivity state changes."""
263        self._have_connectivity = connected
264        self._update_ui_live_state()
265
266    def _update_ui_live_state(self) -> None:
267        # We want to show ui elements faded if we don't have a live
268        # connection to the master-server OR if we haven't received a
269        # set of account values from them yet. If we just plug in raw
270        # connectivity state here we get UI stuff un-fading a moment or
271        # two before values appear (since the subscriptions have not
272        # sent us any values yet) which looks odd.
273        _baclassic.set_root_ui_have_live_values(
274            self._have_connectivity and self._have_account_values
275        )
276
277    def _on_sub_test_update(self, val: int | None) -> None:
278        print(f'GOT SUB TEST UPDATE: {val}')
279
280    def _on_classic_account_data_change(
281        self, val: bacommon.bs.ClassicAccountLiveData
282    ) -> None:
283        # print('ACCOUNT CHANGED:', val)
284        achp = round(val.achievements / max(val.achievements_total, 1) * 100.0)
285        ibc = str(val.inbox_count)
286        if val.inbox_count_is_max:
287            ibc += '+'
288
289        chest0 = val.chests.get('0')
290        chest1 = val.chests.get('1')
291        chest2 = val.chests.get('2')
292        chest3 = val.chests.get('3')
293
294        # Keep a few handy values on classic updated with the latest
295        # data.
296        classic = babase.app.classic
297        assert classic is not None
298        classic.remove_ads = val.remove_ads
299        classic.gold_pass = val.gold_pass
300        classic.chest_dock_full = (
301            chest0 is not None
302            and chest1 is not None
303            and chest2 is not None
304            and chest3 is not None
305        )
306
307        _baclassic.set_root_ui_account_values(
308            tickets=val.tickets,
309            tokens=val.tokens,
310            league_type=(
311                '' if val.league_type is None else val.league_type.value
312            ),
313            league_number=(-1 if val.league_num is None else val.league_num),
314            league_rank=(-1 if val.league_rank is None else val.league_rank),
315            achievements_percent_text=f'{achp}%',
316            level_text=str(val.level),
317            xp_text=f'{val.xp}/{val.xpmax}',
318            inbox_count_text=ibc,
319            gold_pass=val.gold_pass,
320            chest_0_appearance=(
321                '' if chest0 is None else chest0.appearance.value
322            ),
323            chest_1_appearance=(
324                '' if chest1 is None else chest1.appearance.value
325            ),
326            chest_2_appearance=(
327                '' if chest2 is None else chest2.appearance.value
328            ),
329            chest_3_appearance=(
330                '' if chest3 is None else chest3.appearance.value
331            ),
332            chest_0_unlock_time=(
333                -1.0 if chest0 is None else chest0.unlock_time.timestamp()
334            ),
335            chest_1_unlock_time=(
336                -1.0 if chest1 is None else chest1.unlock_time.timestamp()
337            ),
338            chest_2_unlock_time=(
339                -1.0 if chest2 is None else chest2.unlock_time.timestamp()
340            ),
341            chest_3_unlock_time=(
342                -1.0 if chest3 is None else chest3.unlock_time.timestamp()
343            ),
344            chest_0_ad_allow_time=(
345                -1.0
346                if chest0 is None or chest0.ad_allow_time is None
347                else chest0.ad_allow_time.timestamp()
348            ),
349            chest_1_ad_allow_time=(
350                -1.0
351                if chest1 is None or chest1.ad_allow_time is None
352                else chest1.ad_allow_time.timestamp()
353            ),
354            chest_2_ad_allow_time=(
355                -1.0
356                if chest2 is None or chest2.ad_allow_time is None
357                else chest2.ad_allow_time.timestamp()
358            ),
359            chest_3_ad_allow_time=(
360                -1.0
361                if chest3 is None or chest3.ad_allow_time is None
362                else chest3.ad_allow_time.timestamp()
363            ),
364        )
365        if self._should_restore_account_league_vis_vals:
366            # If we have previous league vis vals for this account,
367            # restore them. This will cause us to animate or otherwise
368            # display league changes that have occurred since we were
369            # last visible. Note we need to do this *after* setting real
370            # vals so there is something to animate to.
371            self._restore_account_league_vis_vals()
372            self._should_restore_account_league_vis_vals = False
373
374        # Note that we have values and updated faded state accordingly.
375        self._have_account_values = True
376        self._update_ui_live_state()
377
378    def _root_ui_menu_press(self) -> None:
379        from babase import push_back_press
380
381        ui = babase.app.ui_v1
382
383        # If *any* main-window is up, kill it and resume play.
384        old_window = ui.get_main_window()
385        if old_window is not None:
386
387            classic = babase.app.classic
388            assert classic is not None
389            classic.resume()
390
391            ui.clear_main_window()
392            return
393
394        # Otherwise
395        push_back_press()
396
397    def _root_ui_account_press(self) -> None:
398        from bauiv1lib.account.settings import AccountSettingsWindow
399
400        self._auxiliary_window_nav(
401            win_type=AccountSettingsWindow,
402            win_create_call=lambda: AccountSettingsWindow(
403                origin_widget=bauiv1.get_special_widget('account_button')
404            ),
405        )
406
407    def _root_ui_squad_press(self) -> None:
408        btn = bauiv1.get_special_widget('squad_button')
409        center = btn.get_screen_space_center()
410        if bauiv1.app.classic is not None:
411            bauiv1.app.classic.party_icon_activate(center)
412        else:
413            logging.warning('party_icon_activate: no classic.')
414
415    def _root_ui_settings_press(self) -> None:
416        from bauiv1lib.settings.allsettings import AllSettingsWindow
417
418        self._auxiliary_window_nav(
419            win_type=AllSettingsWindow,
420            win_create_call=lambda: AllSettingsWindow(
421                origin_widget=bauiv1.get_special_widget('settings_button')
422            ),
423        )
424
425    def _auxiliary_window_nav(
426        self,
427        win_type: type[bauiv1.MainWindow],
428        win_create_call: Callable[[], bauiv1.MainWindow],
429    ) -> None:
430        """Navigate to or away from an Auxiliary window.
431
432        Auxiliary windows can be thought of as 'side quests' in the
433        window hierarchy; places such as settings windows or league
434        ranking windows that the user might want to visit without losing
435        their place in the regular hierarchy.
436        """
437        # pylint: disable=unidiomatic-typecheck
438
439        ui = babase.app.ui_v1
440
441        current_main_window = ui.get_main_window()
442
443        # Scan our ancestors for auxiliary states matching our type as
444        # well as auxiliary states in general.
445        aux_matching_state: bauiv1.MainWindowState | None = None
446        aux_state: bauiv1.MainWindowState | None = None
447
448        if current_main_window is None:
449            raise RuntimeError(
450                'Not currently handling no-top-level-window case.'
451            )
452
453        state = current_main_window.main_window_back_state
454        while state is not None:
455            assert state.window_type is not None
456            if state.is_auxiliary:
457                if state.window_type is win_type:
458                    aux_matching_state = state
459                else:
460                    aux_state = state
461
462            state = state.parent
463
464        # If there's an ancestor auxiliary window-state matching our
465        # type, back out past it (example: poking settings, navigating
466        # down a level or two, and then poking settings again should
467        # back out of settings).
468        if aux_matching_state is not None:
469            current_main_window.main_window_back_state = (
470                aux_matching_state.parent
471            )
472            current_main_window.main_window_back()
473            return
474
475        # If there's an ancestory auxiliary state *not* matching our
476        # type, crop the state and swap in our new auxiliary UI
477        # (example: poking settings, then poking account, then poking
478        # back should end up where things were before the settings
479        # poke).
480        if aux_state is not None:
481            # Blow away the window stack and build a fresh one.
482            ui.clear_main_window()
483            ui.set_main_window(
484                win_create_call(),
485                from_window=False,  # Disable from-check.
486                back_state=aux_state.parent,
487                suppress_warning=True,
488                is_auxiliary=True,
489            )
490            return
491
492        # Ok, no auxiliary states found. Now if current window is
493        # auxiliary and the type matches, simply do a back.
494        if (
495            current_main_window.main_window_is_auxiliary
496            and type(current_main_window) is win_type
497        ):
498            current_main_window.main_window_back()
499            return
500
501        # If current window is auxiliary but type doesn't match,
502        # swap it out for our new auxiliary UI.
503        if current_main_window.main_window_is_auxiliary:
504            ui.clear_main_window()
505            ui.set_main_window(
506                win_create_call(),
507                from_window=False,  # Disable from-check.
508                back_state=current_main_window.main_window_back_state,
509                suppress_warning=True,
510                is_auxiliary=True,
511            )
512            return
513
514        # Ok, no existing auxiliary stuff was found period. Just
515        # navigate forward to this UI.
516        current_main_window.main_window_replace(
517            win_create_call(), is_auxiliary=True
518        )
519
520    def _root_ui_achievements_press(self) -> None:
521        from bauiv1lib.achievements import AchievementsWindow
522
523        if not self._ensure_signed_in_v1():
524            return
525
526        wait_for_connectivity(
527            on_connected=lambda: self._auxiliary_window_nav(
528                win_type=AchievementsWindow,
529                win_create_call=lambda: AchievementsWindow(
530                    origin_widget=bauiv1.get_special_widget(
531                        'achievements_button'
532                    )
533                ),
534            )
535        )
536
537    def _root_ui_inbox_press(self) -> None:
538        from bauiv1lib.inbox import InboxWindow
539
540        if not self._ensure_signed_in():
541            return
542
543        wait_for_connectivity(
544            on_connected=lambda: self._auxiliary_window_nav(
545                win_type=InboxWindow,
546                win_create_call=lambda: InboxWindow(
547                    origin_widget=bauiv1.get_special_widget('inbox_button')
548                ),
549            )
550        )
551
552    def _root_ui_store_press(self) -> None:
553        from bauiv1lib.store.browser import StoreBrowserWindow
554
555        if not self._ensure_signed_in_v1():
556            return
557
558        wait_for_connectivity(
559            on_connected=lambda: self._auxiliary_window_nav(
560                win_type=StoreBrowserWindow,
561                win_create_call=lambda: StoreBrowserWindow(
562                    origin_widget=bauiv1.get_special_widget('store_button')
563                ),
564            )
565        )
566
567    def _root_ui_tickets_meter_press(self) -> None:
568        from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
569
570        ResourceTypeInfoWindow(
571            'tickets', origin_widget=bauiv1.get_special_widget('tickets_meter')
572        )
573
574    def _root_ui_tokens_meter_press(self) -> None:
575        from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
576
577        ResourceTypeInfoWindow(
578            'tokens', origin_widget=bauiv1.get_special_widget('tokens_meter')
579        )
580
581    def _root_ui_trophy_meter_press(self) -> None:
582        from bauiv1lib.league.rankwindow import LeagueRankWindow
583
584        if not self._ensure_signed_in_v1():
585            return
586
587        self._auxiliary_window_nav(
588            win_type=LeagueRankWindow,
589            win_create_call=lambda: LeagueRankWindow(
590                origin_widget=bauiv1.get_special_widget('trophy_meter')
591            ),
592        )
593
594    def _root_ui_level_meter_press(self) -> None:
595        from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
596
597        ResourceTypeInfoWindow(
598            'xp', origin_widget=bauiv1.get_special_widget('level_meter')
599        )
600
601    def _root_ui_inventory_press(self) -> None:
602        from bauiv1lib.inventory import InventoryWindow
603
604        if not self._ensure_signed_in_v1():
605            return
606
607        self._auxiliary_window_nav(
608            win_type=InventoryWindow,
609            win_create_call=lambda: InventoryWindow(
610                origin_widget=bauiv1.get_special_widget('inventory_button')
611            ),
612        )
613
614    def _ensure_signed_in(self) -> bool:
615        """Make sure we're signed in (requiring modern v2 accounts)."""
616        plus = bauiv1.app.plus
617        if plus is None:
618            bauiv1.screenmessage('This requires plus.', color=(1, 0, 0))
619            bauiv1.getsound('error').play()
620            return False
621        if plus.accounts.primary is None:
622            show_sign_in_prompt()
623            return False
624        return True
625
626    def _ensure_signed_in_v1(self) -> bool:
627        """Make sure we're signed in (allowing legacy v1-only accounts)."""
628        plus = bauiv1.app.plus
629        if plus is None:
630            bauiv1.screenmessage('This requires plus.', color=(1, 0, 0))
631            bauiv1.getsound('error').play()
632            return False
633        if plus.get_v1_account_state() != 'signed_in':
634            show_sign_in_prompt()
635            return False
636        return True
637
638    def _root_ui_get_tokens_press(self) -> None:
639        from bauiv1lib.gettokens import GetTokensWindow
640
641        if not self._ensure_signed_in():
642            return
643
644        self._auxiliary_window_nav(
645            win_type=GetTokensWindow,
646            win_create_call=lambda: GetTokensWindow(
647                origin_widget=bauiv1.get_special_widget('get_tokens_button')
648            ),
649        )
650
651    def _root_ui_chest_slot_pressed(self, index: int) -> None:
652        from bauiv1lib.chest import (
653            ChestWindow0,
654            ChestWindow1,
655            ChestWindow2,
656            ChestWindow3,
657        )
658
659        widgetid: Literal[
660            'chest_0_button',
661            'chest_1_button',
662            'chest_2_button',
663            'chest_3_button',
664        ]
665        winclass: type[ChestWindow]
666        if index == 0:
667            widgetid = 'chest_0_button'
668            winclass = ChestWindow0
669        elif index == 1:
670            widgetid = 'chest_1_button'
671            winclass = ChestWindow1
672        elif index == 2:
673            widgetid = 'chest_2_button'
674            winclass = ChestWindow2
675        elif index == 3:
676            widgetid = 'chest_3_button'
677            winclass = ChestWindow3
678        else:
679            raise RuntimeError(f'Invalid index {index}')
680
681        wait_for_connectivity(
682            on_connected=lambda: self._auxiliary_window_nav(
683                win_type=winclass,
684                win_create_call=lambda: winclass(
685                    index=index,
686                    origin_widget=bauiv1.get_special_widget(widgetid),
687                ),
688            )
689        )
690
691    def _save_account_league_vis_vals(self) -> None:
692
693        # If we currently have an account, save any currently-displayed
694        # league info alongside the account id. We'll then restore this
695        # state as a starting point the next time we are active, meaning
696        # any changes that occur while we're away will be animated for
697        # the user to see.
698
699        if self._current_account_id is not None:
700            vals = _baclassic.get_root_ui_account_league_vis_values()
701            if vals is not None:
702                assert 'a' not in vals
703                vals['a'] = self._current_account_id
704                cfg = babase.app.config
705                cfg[self.LEAGUE_VIS_VALS_CONFIG_KEY] = vals
706                cfg.commit()
707
708    def _restore_account_league_vis_vals(self) -> None:
709
710        # If we currently have an account and it matches vis-vals we have
711        # stored in the config, restore those values.
712        if self._current_account_id is not None:
713            cfg = babase.app.config
714            vals = cfg.get(self.LEAGUE_VIS_VALS_CONFIG_KEY)
715            if isinstance(vals, dict):
716                valsaccount = vals.get('a')
717                if (
718                    isinstance(valsaccount, str)
719                    and valsaccount == self._current_account_id
720                ):
721                    _baclassic.set_root_ui_account_league_vis_values(vals)
722
723    def on_engine_will_reset(self) -> None:
724        """Called just before classic resets the engine.
725
726        This happens at various times such as session switches.
727        """
728
729        self._save_account_league_vis_vals()
730
731    def on_engine_did_reset(self) -> None:
732        """Called just after classic resets the engine.
733
734        This happens at various times such as session switches.
735        """
736
737        # Restore any old league vis state we had; this allows the user
738        # to see league improvements/etc. that occurred while we were
739        # gone.
740        self._restore_account_league_vis_vals()

AppMode for the classic BombSquad experience.

LEAGUE_VIS_VALS_CONFIG_KEY = 'ClassicLeagueVisVals'
@override
@classmethod
def get_app_experience(cls) -> bacommon.app.AppExperience:
49    @override
50    @classmethod
51    def get_app_experience(cls) -> AppExperience:
52        return AppExperience.MELEE

Return the overall experience provided by this mode.

@override
def handle_intent(self, intent: babase.AppIntent) -> None:
62    @override
63    def handle_intent(self, intent: babase.AppIntent) -> None:
64        if isinstance(intent, babase.AppIntentExec):
65            _baclassic.classic_app_mode_handle_app_intent_exec(intent.code)
66            return
67        assert isinstance(intent, babase.AppIntentDefault)
68        _baclassic.classic_app_mode_handle_app_intent_default()

Handle an intent.

@override
def on_activate(self) -> None:
 70    @override
 71    def on_activate(self) -> None:
 72
 73        # Let the native layer do its thing.
 74        _baclassic.classic_app_mode_activate()
 75
 76        app = babase.app
 77        plus = app.plus
 78        assert plus is not None
 79
 80        # Wire up the root ui to do what we want.
 81        ui = app.ui_v1
 82        ui.root_ui_calls[ui.RootUIElement.ACCOUNT_BUTTON] = (
 83            self._root_ui_account_press
 84        )
 85        ui.root_ui_calls[ui.RootUIElement.MENU_BUTTON] = (
 86            self._root_ui_menu_press
 87        )
 88        ui.root_ui_calls[ui.RootUIElement.SQUAD_BUTTON] = (
 89            self._root_ui_squad_press
 90        )
 91        ui.root_ui_calls[ui.RootUIElement.SETTINGS_BUTTON] = (
 92            self._root_ui_settings_press
 93        )
 94        ui.root_ui_calls[ui.RootUIElement.STORE_BUTTON] = (
 95            self._root_ui_store_press
 96        )
 97        ui.root_ui_calls[ui.RootUIElement.INVENTORY_BUTTON] = (
 98            self._root_ui_inventory_press
 99        )
100        ui.root_ui_calls[ui.RootUIElement.GET_TOKENS_BUTTON] = (
101            self._root_ui_get_tokens_press
102        )
103        ui.root_ui_calls[ui.RootUIElement.INBOX_BUTTON] = (
104            self._root_ui_inbox_press
105        )
106        ui.root_ui_calls[ui.RootUIElement.TICKETS_METER] = (
107            self._root_ui_tickets_meter_press
108        )
109        ui.root_ui_calls[ui.RootUIElement.TOKENS_METER] = (
110            self._root_ui_tokens_meter_press
111        )
112        ui.root_ui_calls[ui.RootUIElement.TROPHY_METER] = (
113            self._root_ui_trophy_meter_press
114        )
115        ui.root_ui_calls[ui.RootUIElement.LEVEL_METER] = (
116            self._root_ui_level_meter_press
117        )
118        ui.root_ui_calls[ui.RootUIElement.ACHIEVEMENTS_BUTTON] = (
119            self._root_ui_achievements_press
120        )
121        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_0] = partial(
122            self._root_ui_chest_slot_pressed, 0
123        )
124        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_1] = partial(
125            self._root_ui_chest_slot_pressed, 1
126        )
127        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_2] = partial(
128            self._root_ui_chest_slot_pressed, 2
129        )
130        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_3] = partial(
131            self._root_ui_chest_slot_pressed, 3
132        )
133
134        # We want to be informed when connectivity changes.
135        self._on_connectivity_changed_callback = (
136            plus.cloud.on_connectivity_changed_callbacks.register(
137                self._update_for_connectivity_change
138            )
139        )
140        # We want to be informed when primary account changes.
141        self._on_primary_account_changed_callback = (
142            plus.accounts.on_primary_account_changed_callbacks.register(
143                self._update_for_primary_account
144            )
145        )
146        # Establish subscriptions/etc. for any current primary account.
147        self._update_for_primary_account(plus.accounts.primary)
148        self._have_connectivity = plus.cloud.is_connected()
149        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:
151    @override
152    def on_deactivate(self) -> None:
153
154        classic = babase.app.classic
155
156        # Store latest league vis vals for any active account.
157        self._save_account_league_vis_vals()
158
159        # Stop being informed of account changes.
160        self._on_primary_account_changed_callback = None
161
162        # Remove anything following any current account.
163        self._update_for_primary_account(None)
164
165        # Save where we were in the UI so we return there next time.
166        if classic is not None:
167            classic.save_ui_state()
168
169        # Let the native layer do its thing.
170        _baclassic.classic_app_mode_deactivate()

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

Note: 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:
172    @override
173    def on_app_active_changed(self) -> None:
174        if not babase.app.active:
175            # If we've gone inactive, bring up the main menu, which has the
176            # side effect of pausing the action (when possible).
177            babase.invoke_main_menu()
178
179            # Also store any league vis state for the active account.
180            # this may be our last chance to do this on mobile.
181            self._save_account_league_vis_vals()

Called when ba*.app.active changes while in this app-mode.

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.

Note: 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).

def on_engine_will_reset(self) -> None:
723    def on_engine_will_reset(self) -> None:
724        """Called just before classic resets the engine.
725
726        This happens at various times such as session switches.
727        """
728
729        self._save_account_league_vis_vals()

Called just before classic resets the engine.

This happens at various times such as session switches.

def on_engine_did_reset(self) -> None:
731    def on_engine_did_reset(self) -> None:
732        """Called just after classic resets the engine.
733
734        This happens at various times such as session switches.
735        """
736
737        # Restore any old league vis state we had; this allows the user
738        # to see league improvements/etc. that occurred while we were
739        # gone.
740        self._restore_account_league_vis_vals()

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

Name of the current platform.

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

subplatform: str
151    @property
152    def subplatform(self) -> str:
153        """String for subplatform.
154
155        Can be empty. For the 'android' platform, subplatform may
156        be 'google', 'amazon', etc.
157        """
158        assert isinstance(self._env['subplatform'], str)
159        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
161    @property
162    def legacy_user_agent_string(self) -> str:
163        """String containing various bits of info about OS/device/etc."""
164        assert isinstance(self._env['legacy_user_agent_string'], str)
165        return self._env['legacy_user_agent_string']

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

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

Called when the app enters the suspended state.

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

Called when the app exits the suspended state.

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

Called when the app begins shutting down.

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

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:
259    def resume(self) -> None:
260        """Resume the game due to a user request or menu closing.
261
262        If there's a foreground host-activity that's currently paused, tell it
263        to resume.
264        """
265
266        # FIXME: Shouldn't be touching scene stuff here; should just
267        #  pass the request on to the host-session.
268        activity = bascenev1.get_foreground_host_activity()
269        if activity is not None:
270            with activity.context:
271                globs = activity.globalsnode
272                if globs.paused:
273                    bascenev1.getsound('refWhistle').play()
274                    globs.paused = False
275
276                    # FIXME: This should not be an actor attr.
277                    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:
279    def add_coop_practice_level(self, level: bascenev1.Level) -> None:
280        """Adds an individual level to the 'practice' section in Co-op."""
281
282        # Assign this level to our catch-all campaign.
283        self.campaigns['Challenges'].addlevel(level)
284
285        # Make note to add it to our challenges UI.
286        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:
288    def launch_coop_game(
289        self, game: str, force: bool = False, args: dict | None = None
290    ) -> bool:
291        """High level way to launch a local co-op session."""
292        # pylint: disable=cyclic-import
293        from bauiv1lib.coop.level import CoopLevelLockedWindow
294
295        assert babase.app.classic is not None
296
297        if args is None:
298            args = {}
299        if game == '':
300            raise ValueError('empty game name')
301        campaignname, levelname = game.split(':')
302        campaign = babase.app.classic.getcampaign(campaignname)
303
304        # If this campaign is sequential, make sure we've completed the
305        # one before this.
306        if campaign.sequential and not force:
307            for level in campaign.levels:
308                if level.name == levelname:
309                    break
310                if not level.complete:
311                    CoopLevelLockedWindow(
312                        campaign.getlevel(levelname).displayname,
313                        campaign.getlevel(level.name).displayname,
314                    )
315                    return False
316
317        # Save where we are in the UI to come back to when done.
318        babase.app.classic.save_ui_state()
319
320        # Ok, we're good to go.
321        self.coop_session_args = {
322            'campaign': campaignname,
323            'level': levelname,
324        }
325        for arg_name, arg_val in list(args.items()):
326            self.coop_session_args[arg_name] = arg_val
327
328        def _fade_end() -> None:
329            from bascenev1 import CoopSession
330
331            try:
332                bascenev1.new_host_session(CoopSession)
333            except Exception:
334                logging.exception('Error creating coopsession after fade end.')
335                from bascenev1lib.mainmenu import MainMenuSession
336
337                bascenev1.new_host_session(MainMenuSession)
338
339        babase.fade_screen(False, endcall=_fade_end)
340        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:
342    def return_to_main_menu_session_gracefully(
343        self, reset_ui: bool = True
344    ) -> None:
345        """Attempt to cleanly get back to the main menu."""
346        # pylint: disable=cyclic-import
347        from baclassic import _benchmark
348        from bascenev1lib.mainmenu import MainMenuSession
349
350        plus = babase.app.plus
351        assert plus is not None
352
353        if reset_ui:
354            babase.app.ui_v1.clear_main_window()
355
356        if isinstance(bascenev1.get_foreground_host_session(), MainMenuSession):
357            # It may be possible we're on the main menu but the screen
358            # is faded so fade back in.
359            babase.fade_screen(True)
360            return
361
362        _benchmark.stop_stress_test()  # Stop stress-test if in progress.
363
364        # If we're in a host-session, tell them to end. This lets them
365        # tear themselves down gracefully.
366        host_session: bascenev1.Session | None = (
367            bascenev1.get_foreground_host_session()
368        )
369        if host_session is not None:
370            # Kick off a little transaction so we'll hopefully have all
371            # the latest account state when we get back to the menu.
372            plus.add_v1_account_transaction(
373                {'type': 'END_SESSION', 'sType': str(type(host_session))}
374            )
375            plus.run_v1_account_transactions()
376
377            host_session.end()
378
379        # Otherwise just force the issue.
380        else:
381            babase.pushcall(
382                babase.Call(bascenev1.new_host_session, MainMenuSession)
383            )

Attempt to cleanly get back to the main menu.

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

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

Category: Asset Functions

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

Play Types:

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

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

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

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

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

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

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

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

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

@classmethod
def json_prep(cls, data: Any) -> Any:
442    @classmethod
443    def json_prep(cls, data: Any) -> Any:
444        """Return a json-friendly version of the provided data.
445
446        This converts any tuples to lists and any bytes to strings
447        (interpreted as utf-8, ignoring errors). Logs errors (just once)
448        if any data is modified/discarded/unsupported.
449        """
450
451        if isinstance(data, dict):
452            return dict(
453                (cls.json_prep(key), cls.json_prep(value))
454                for key, value in list(data.items())
455            )
456        if isinstance(data, list):
457            return [cls.json_prep(element) for element in data]
458        if isinstance(data, tuple):
459            logging.exception('json_prep encountered tuple')
460            return [cls.json_prep(element) for element in data]
461        if isinstance(data, bytes):
462            try:
463                return data.decode(errors='ignore')
464            except Exception:
465                logging.exception('json_prep encountered utf-8 decode error')
466                return data.decode(errors='ignore')
467        if not isinstance(data, (str, float, bool, type(None), int)):
468            logging.exception(
469                'got unsupported type in json_prep: %s', type(data)
470            )
471        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:
473    def master_server_v1_get(
474        self,
475        request: str,
476        data: dict[str, Any],
477        callback: MasterServerCallback | None = None,
478        response_type: MasterServerResponseType = MasterServerResponseType.JSON,
479    ) -> None:
480        """Make a call to the master server via a http GET."""
481
482        MasterServerV1CallThread(
483            request, 'get', data, callback, response_type
484        ).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:
486    def master_server_v1_post(
487        self,
488        request: str,
489        data: dict[str, Any],
490        callback: MasterServerCallback | None = None,
491        response_type: MasterServerResponseType = MasterServerResponseType.JSON,
492    ) -> None:
493        """Make a call to the master server via a http POST."""
494        MasterServerV1CallThread(
495            request, 'post', data, callback, response_type
496        ).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:
498    def set_tournament_prize_image(
499        self, entry: dict[str, Any], index: int, image: bauiv1.Widget
500    ) -> None:
501        """Given a tournament entry, return strings for its prize levels."""
502        from baclassic import _tournament
503
504        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:
506    def create_in_game_tournament_prize_image(
507        self,
508        entry: dict[str, Any],
509        index: int,
510        position: tuple[float, float],
511    ) -> None:
512        """Given a tournament entry, return strings for its prize levels."""
513        from baclassic import _tournament
514
515        _tournament.create_in_game_tournament_prize_image(
516            entry, index, position
517        )

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

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

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

Return a campaign by name.

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

Returns the next tip to be displayed.

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

Kick off a benchmark to test cpu speeds.

def run_media_reload_benchmark(self) -> None:
547    def run_media_reload_benchmark(self) -> None:
548        """Kick off a benchmark to test media reloading speeds."""
549        from baclassic._benchmark import run_media_reload_benchmark
550
551        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:
553    def run_stress_test(
554        self,
555        *,
556        playlist_type: str = 'Random',
557        playlist_name: str = '__default__',
558        player_count: int = 8,
559        round_duration: int = 30,
560        attract_mode: bool = False,
561    ) -> None:
562        """Run a stress test."""
563        from baclassic._benchmark import run_stress_test
564
565        run_stress_test(
566            playlist_type=playlist_type,
567            playlist_name=playlist_name,
568            player_count=player_count,
569            round_duration=round_duration,
570            attract_mode=attract_mode,
571        )

Run a stress test.

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

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:
588    def get_input_device_map_hash(
589        self, inputdevice: bascenev1.InputDevice
590    ) -> str:
591        """Given an input device, return hash based on its raw input values."""
592        del inputdevice  # unused currently
593        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]:
595    def get_input_device_config(
596        self, inputdevice: bascenev1.InputDevice, default: bool
597    ) -> tuple[dict, str]:
598        """Given an input device, return its config dict in the app config.
599
600        The dict will be created if it does not exist.
601        """
602        return _input.get_input_device_config(
603            inputdevice.name, inputdevice.unique_identifier, default
604        )

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]]:
606    def get_player_colors(self) -> list[tuple[float, float, float]]:
607        """Return user-selectable player colors."""
608        return bascenev1.get_player_colors()

Return user-selectable player colors.

def get_player_profile_icon(self, profilename: str) -> str:
610    def get_player_profile_icon(self, profilename: str) -> str:
611        """Given a profile name, returns an icon string for it.
612
613        (non-account profiles only)
614        """
615        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]]:
617    def get_player_profile_colors(
618        self,
619        profilename: str | None,
620        profiles: dict[str, dict[str, Any]] | None = None,
621    ) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
622        """Given a profile, return colors for them."""
623        return bascenev1.get_player_profile_colors(profilename, profiles)

Given a profile, return colors for them.

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

Preload media needed for map preview UIs.

Category: Asset Functions

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

Store our current place in the UI.

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

Bring up main menu ui.

@staticmethod
def run_bs_client_effects(effects: list[bacommon.bs.ClientEffect]) -> None:
860    @staticmethod
861    def run_bs_client_effects(effects: list[bacommon.bs.ClientEffect]) -> None:
862        """Run client effects sent from the master server."""
863        from baclassic._clienteffect import run_bs_client_effects
864
865        run_bs_client_effects(effects)

Run client effects sent from the master server.

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

Given a client-ui label, return an Lstr.

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

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

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

Is a particular game unlocked?

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

Influences behavior when playing music.

Category: Enums

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

Represents attributes and state for an individual achievement.

Category: App Classes

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

The name of this achievement.

level_name: str
563    @property
564    def level_name(self) -> str:
565        """The name of the level this achievement applies to."""
566        return self._level_name

The name of the level this achievement applies to.

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

Return the icon texture to display for this achievement

def get_icon_texture(self, complete: bool) -> _bascenev1.Texture:
574    def get_icon_texture(self, complete: bool) -> bascenev1.Texture:
575        """Return the icon texture to display for this achievement"""
576        return bascenev1.gettexture(
577            self._icon_name if complete else 'achievementEmpty'
578        )

Return the icon texture to display for this achievement

def get_icon_color(self, complete: bool) -> Sequence[float]:
580    def get_icon_color(self, complete: bool) -> Sequence[float]:
581        """Return the color tint for this Achievement's icon."""
582        if complete:
583            return self._icon_color
584        return 1.0, 1.0, 1.0, 0.6

Return the color tint for this Achievement's icon.

hard_mode_only: bool
586    @property
587    def hard_mode_only(self) -> bool:
588        """Whether this Achievement is only unlockable in hard-mode."""
589        return self._hard_mode_only

Whether this Achievement is only unlockable in hard-mode.

complete: bool
591    @property
592    def complete(self) -> bool:
593        """Whether this Achievement is currently complete."""
594        val: bool = self._getconfig()['Complete']
595        assert isinstance(val, bool)
596        return val

Whether this Achievement is currently complete.

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

Kick off an announcement for this achievement's completion.

def set_complete(self, complete: bool = True) -> None:
637    def set_complete(self, complete: bool = True) -> None:
638        """Set an achievement's completed state.
639
640        note this only sets local state; use a transaction to
641        actually award achievements.
642        """
643        config = self._getconfig()
644        if complete != config['Complete']:
645            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
647    @property
648    def display_name(self) -> babase.Lstr:
649        """Return a babase.Lstr for this Achievement's name."""
650        name: babase.Lstr | str
651        try:
652            if self._level_name != '':
653                campaignname, campaign_level = self._level_name.split(':')
654                classic = babase.app.classic
655                assert classic is not None
656                name = (
657                    classic.getcampaign(campaignname)
658                    .getlevel(campaign_level)
659                    .displayname
660                )
661            else:
662                name = ''
663        except Exception:
664            name = ''
665            logging.exception('Error calcing achievement display-name.')
666        return babase.Lstr(
667            resource='achievements.' + self._name + '.name',
668            subs=[('${LEVEL}', name)],
669        )

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

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

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

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

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

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

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

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

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

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

Return the type of chest given for this achievement.

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

Create a display for the Achievement.

Shows the Achievement icon, name, and description.

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

Create the banner/sound for an acquired achievement announcement.

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

Subsystem for achievement handling.

Category: App Classes

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:
401    def award_local_achievement(self, achname: str) -> None:
402        """For non-game-based achievements such as controller-connection."""
403        plus = babase.app.plus
404        if plus is None:
405            logging.warning('achievements require plus feature-set')
406            return
407        try:
408            ach = self.get_achievement(achname)
409            if not ach.complete:
410                # Report new achievements to the game-service.
411                plus.report_achievement(achname)
412
413                # And to our account.
414                plus.add_v1_account_transaction(
415                    {'type': 'ACHIEVEMENT', 'name': achname}
416                )
417
418                # Now attempt to show a banner.
419                self.display_achievement_banner(achname)
420
421        except Exception:
422            logging.exception('Error in award_local_achievement.')

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

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

Return an Achievement by name.

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

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.