baclassic

Components for the classic BombSquad experience.

This package is used as a dumping ground for functionality that is necessary to keep classic BombSquad working, but which may no longer be the best way to do things going forward.

New code should try to avoid using code from here when possible.

Functionality in this package should be exposed through the ClassicAppSubsystem. This allows type-checked code to go through the babase.app.classic singleton which forces it to explicitly handle the possibility of babase.app.classic being None. When code instead imports classic submodules directly, it is much harder to make it cleanly handle classic not being present.

 1# Released under the MIT License. See LICENSE for details.
 2#
 3"""Components for the classic BombSquad experience.
 4
 5This package is used as a dumping ground for functionality that is
 6necessary to keep classic BombSquad working, but which may no longer be
 7the best way to do things going forward.
 8
 9New code should try to avoid using code from here when possible.
10
11Functionality in this package should be exposed through the
12ClassicAppSubsystem. This allows type-checked code to go through the
13babase.app.classic singleton which forces it to explicitly handle the
14possibility of babase.app.classic being None. When code instead imports
15classic submodules directly, it is much harder to make it cleanly handle
16classic not being present.
17"""
18
19# ba_meta require api 9
20
21# Note: Code relying on classic should import things from here *only*
22# for type-checking and use the versions in ba*.app.classic at runtime;
23# that way type-checking will cleanly cover the classic-not-present case
24# (ba*.app.classic being None).
25import logging
26
27from efro.util import set_canonical_module_names
28
29from baclassic._appmode import ClassicAppMode
30from baclassic._appsubsystem import ClassicAppSubsystem
31from baclassic._achievement import Achievement, AchievementSubsystem
32
33__all__ = [
34    'ClassicAppMode',
35    'ClassicAppSubsystem',
36    'Achievement',
37    'AchievementSubsystem',
38]
39
40# We want stuff here to show up as packagename.Foo instead of
41# packagename._submodule.Foo.
42set_canonical_module_names(globals())
43
44# Sanity check: we want to keep ballistica's dependencies and
45# bootstrapping order clearly defined; let's check a few particular
46# modules to make sure they never directly or indirectly import us
47# before their own execs complete.
48if __debug__:
49    for _mdl in 'babase', '_babase':
50        if not hasattr(__import__(_mdl), '_REACHED_END_OF_MODULE'):
51            logging.warning(
52                '%s was imported before %s finished importing;'
53                ' should not happen.',
54                __name__,
55                _mdl,
56            )
class ClassicAppMode(babase._appmode.AppMode):
 33class ClassicAppMode(AppMode):
 34    """AppMode for the classic BombSquad experience."""
 35
 36    def __init__(self) -> None:
 37        self._on_primary_account_changed_callback: (
 38            CallbackRegistration | None
 39        ) = None
 40        self._test_sub: CloudSubscription | None = None
 41
 42    @override
 43    @classmethod
 44    def get_app_experience(cls) -> AppExperience:
 45        return AppExperience.MELEE
 46
 47    @override
 48    @classmethod
 49    def _supports_intent(cls, intent: AppIntent) -> bool:
 50        # We support default and exec intents currently.
 51        return isinstance(intent, AppIntentExec | AppIntentDefault)
 52
 53    @override
 54    def handle_intent(self, intent: AppIntent) -> None:
 55        if isinstance(intent, AppIntentExec):
 56            _baclassic.classic_app_mode_handle_app_intent_exec(intent.code)
 57            return
 58        assert isinstance(intent, AppIntentDefault)
 59        _baclassic.classic_app_mode_handle_app_intent_default()
 60
 61    @override
 62    def on_activate(self) -> None:
 63
 64        # Let the native layer do its thing.
 65        _baclassic.classic_app_mode_activate()
 66
 67        assert app.plus is not None
 68
 69        # Wire up the root ui to do what we want.
 70        ui = app.ui_v1
 71        ui.root_ui_calls[ui.RootUIElement.ACCOUNT_BUTTON] = (
 72            self._root_ui_account_press
 73        )
 74        ui.root_ui_calls[ui.RootUIElement.MENU_BUTTON] = (
 75            self._root_ui_menu_press
 76        )
 77        ui.root_ui_calls[ui.RootUIElement.SQUAD_BUTTON] = (
 78            self._root_ui_squad_press
 79        )
 80        ui.root_ui_calls[ui.RootUIElement.SETTINGS_BUTTON] = (
 81            self._root_ui_settings_press
 82        )
 83        ui.root_ui_calls[ui.RootUIElement.STORE_BUTTON] = (
 84            self._root_ui_store_press
 85        )
 86        ui.root_ui_calls[ui.RootUIElement.INVENTORY_BUTTON] = (
 87            self._root_ui_inventory_press
 88        )
 89        ui.root_ui_calls[ui.RootUIElement.GET_TOKENS_BUTTON] = (
 90            self._root_ui_get_tokens_press
 91        )
 92        ui.root_ui_calls[ui.RootUIElement.INBOX_BUTTON] = (
 93            self._root_ui_inbox_press
 94        )
 95        ui.root_ui_calls[ui.RootUIElement.TICKETS_METER] = (
 96            self._root_ui_tickets_meter_press
 97        )
 98        ui.root_ui_calls[ui.RootUIElement.TOKENS_METER] = (
 99            self._root_ui_tokens_meter_press
100        )
101        ui.root_ui_calls[ui.RootUIElement.TROPHY_METER] = (
102            self._root_ui_trophy_meter_press
103        )
104        ui.root_ui_calls[ui.RootUIElement.LEVEL_METER] = (
105            self._root_ui_level_meter_press
106        )
107        ui.root_ui_calls[ui.RootUIElement.ACHIEVEMENTS_BUTTON] = (
108            self._root_ui_achievements_press
109        )
110        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_1] = partial(
111            self._root_ui_chest_slot_pressed, 1
112        )
113        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_2] = partial(
114            self._root_ui_chest_slot_pressed, 2
115        )
116        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_3] = partial(
117            self._root_ui_chest_slot_pressed, 3
118        )
119        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_4] = partial(
120            self._root_ui_chest_slot_pressed, 4
121        )
122
123        # We want to be informed when primary account changes.
124        self._on_primary_account_changed_callback = (
125            app.plus.accounts.on_primary_account_changed_callbacks.add(
126                self.update_for_primary_account
127            )
128        )
129        # Establish subscriptions/etc. for any current primary account.
130        self.update_for_primary_account(app.plus.accounts.primary)
131
132    @override
133    def on_deactivate(self) -> None:
134
135        # Stop being informed of account changes.
136        self._on_primary_account_changed_callback = None
137
138        # Remove any listeners for any current primary account.
139        self.update_for_primary_account(None)
140
141        # Save where we were in the UI so we return there next time.
142        if app.classic is not None:
143            app.classic.save_ui_state()
144
145        # Let the native layer do its thing.
146        _baclassic.classic_app_mode_deactivate()
147
148    @override
149    def on_app_active_changed(self) -> None:
150        # If we've gone inactive, bring up the main menu, which has the
151        # side effect of pausing the action (when possible).
152        if not app.active:
153            invoke_main_menu()
154
155    def update_for_primary_account(
156        self, account: AccountV2Handle | None
157    ) -> None:
158        """Update subscriptions/etc. for a new primary account state."""
159        assert in_logic_thread()
160
161        # Test subscription.
162        if bool(False):
163            assert app.plus is not None
164            if account is None:
165                self._test_sub = None
166            else:
167                with account:
168                    self._test_sub = app.plus.cloud.subscribe_test(
169                        self._on_sub_test_update
170                    )
171        else:
172            self._test_sub = None
173
174    def _on_sub_test_update(self, val: int | None) -> None:
175        print(f'GOT SUB TEST UPDATE: {val}')
176
177    def _root_ui_menu_press(self) -> None:
178        from babase import push_back_press
179
180        ui = app.ui_v1
181
182        # If *any* main-window is up, kill it and resume play.
183        old_window = ui.get_main_window()
184        if old_window is not None:
185
186            classic = app.classic
187
188            assert classic is not None
189            classic.resume()
190
191            ui.clear_main_window()
192            return
193
194        push_back_press()
195
196    def _root_ui_account_press(self) -> None:
197        import bauiv1
198        from bauiv1lib.account.settings import AccountSettingsWindow
199
200        self._auxiliary_window_nav(
201            win_type=AccountSettingsWindow,
202            win_create_call=lambda: AccountSettingsWindow(
203                origin_widget=bauiv1.get_special_widget('account_button')
204            ),
205        )
206
207    def _root_ui_squad_press(self) -> None:
208        import bauiv1
209
210        btn = bauiv1.get_special_widget('squad_button')
211        center = btn.get_screen_space_center()
212        if bauiv1.app.classic is not None:
213            bauiv1.app.classic.party_icon_activate(center)
214        else:
215            logging.warning('party_icon_activate: no classic.')
216
217    def _root_ui_settings_press(self) -> None:
218        import bauiv1
219        from bauiv1lib.settings.allsettings import AllSettingsWindow
220
221        self._auxiliary_window_nav(
222            win_type=AllSettingsWindow,
223            win_create_call=lambda: AllSettingsWindow(
224                origin_widget=bauiv1.get_special_widget('settings_button')
225            ),
226        )
227
228    def _auxiliary_window_nav(
229        self,
230        win_type: type[MainWindow],
231        win_create_call: Callable[[], MainWindow],
232    ) -> None:
233        """Navigate to or away from a particular type of Auxiliary window."""
234        # pylint: disable=unidiomatic-typecheck
235
236        ui = app.ui_v1
237
238        current_main_window = ui.get_main_window()
239
240        # Scan our ancestors for auxiliary states matching our type as
241        # well as auxiliary states in general.
242        aux_matching_state: MainWindowState | None = None
243        aux_state: MainWindowState | None = None
244
245        if current_main_window is None:
246            raise RuntimeError(
247                'Not currently handling no-top-level-window case.'
248            )
249
250        state = current_main_window.main_window_back_state
251        while state is not None:
252            assert state.window_type is not None
253            if state.is_auxiliary:
254                if state.window_type is win_type:
255                    aux_matching_state = state
256                else:
257                    aux_state = state
258
259            state = state.parent
260
261        # If there's an ancestor auxiliary window-state matching our
262        # type, back out past it (example: poking settings, navigating
263        # down a level or two, and then poking settings again should
264        # back out of settings).
265        if aux_matching_state is not None:
266            current_main_window.main_window_back_state = (
267                aux_matching_state.parent
268            )
269            current_main_window.main_window_back()
270            return
271
272        # If there's an ancestory auxiliary state *not* matching our
273        # type, crop the state and swap in our new auxiliary UI
274        # (example: poking settings, then poking account, then poking
275        # back should end up where things were before the settings
276        # poke).
277        if aux_state is not None:
278            # Blow away the window stack and build a fresh one.
279            ui.clear_main_window()
280            ui.set_main_window(
281                win_create_call(),
282                from_window=False,  # Disable from-check.
283                back_state=aux_state.parent,
284                suppress_warning=True,
285                is_auxiliary=True,
286            )
287            return
288
289        # Ok, no auxiliary states found. Now if current window is auxiliary
290        # and the type matches, simply do a back.
291        if (
292            current_main_window.main_window_is_auxiliary
293            and type(current_main_window) is win_type
294        ):
295            current_main_window.main_window_back()
296            return
297
298        # If current window is auxiliary but type doesn't match,
299        # swap it out for our new auxiliary UI.
300        if current_main_window.main_window_is_auxiliary:
301            ui.clear_main_window()
302            ui.set_main_window(
303                win_create_call(),
304                from_window=False,  # Disable from-check.
305                back_state=current_main_window.main_window_back_state,
306                suppress_warning=True,
307                is_auxiliary=True,
308            )
309            return
310
311        # Ok, no existing auxiliary stuff was found period. Just
312        # navigate forward to this UI.
313        current_main_window.main_window_replace(
314            win_create_call(), is_auxiliary=True
315        )
316
317    def _root_ui_achievements_press(self) -> None:
318        import bauiv1
319        from bauiv1lib.achievements import AchievementsWindow
320
321        self._auxiliary_window_nav(
322            win_type=AchievementsWindow,
323            win_create_call=lambda: AchievementsWindow(
324                origin_widget=bauiv1.get_special_widget('achievements_button')
325            ),
326        )
327
328    def _root_ui_inbox_press(self) -> None:
329        import bauiv1
330        from bauiv1lib.inbox import InboxWindow
331
332        self._auxiliary_window_nav(
333            win_type=InboxWindow,
334            win_create_call=lambda: InboxWindow(
335                origin_widget=bauiv1.get_special_widget('inbox_button')
336            ),
337        )
338
339    def _root_ui_store_press(self) -> None:
340        import bauiv1
341        from bauiv1lib.store.browser import StoreBrowserWindow
342
343        self._auxiliary_window_nav(
344            win_type=StoreBrowserWindow,
345            win_create_call=lambda: StoreBrowserWindow(
346                origin_widget=bauiv1.get_special_widget('store_button')
347            ),
348        )
349
350    def _root_ui_tickets_meter_press(self) -> None:
351        import bauiv1
352        from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
353
354        ResourceTypeInfoWindow(
355            'tickets', origin_widget=bauiv1.get_special_widget('tickets_meter')
356        )
357
358    def _root_ui_tokens_meter_press(self) -> None:
359        import bauiv1
360        from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
361
362        ResourceTypeInfoWindow(
363            'tokens', origin_widget=bauiv1.get_special_widget('tokens_meter')
364        )
365
366    def _root_ui_trophy_meter_press(self) -> None:
367        import bauiv1
368        from bauiv1lib.account import show_sign_in_prompt
369        from bauiv1lib.league.rankwindow import LeagueRankWindow
370
371        plus = bauiv1.app.plus
372        assert plus is not None
373        if plus.get_v1_account_state() != 'signed_in':
374            show_sign_in_prompt()
375            return
376
377        self._auxiliary_window_nav(
378            win_type=LeagueRankWindow,
379            win_create_call=lambda: LeagueRankWindow(
380                origin_widget=bauiv1.get_special_widget('trophy_meter')
381            ),
382        )
383
384    def _root_ui_level_meter_press(self) -> None:
385        import bauiv1
386        from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
387
388        ResourceTypeInfoWindow(
389            'xp', origin_widget=bauiv1.get_special_widget('level_meter')
390        )
391
392    def _root_ui_inventory_press(self) -> None:
393        import bauiv1
394        from bauiv1lib.inventory import InventoryWindow
395
396        self._auxiliary_window_nav(
397            win_type=InventoryWindow,
398            win_create_call=lambda: InventoryWindow(
399                origin_widget=bauiv1.get_special_widget('inventory_button')
400            ),
401        )
402
403    def _root_ui_get_tokens_press(self) -> None:
404        import bauiv1
405        from bauiv1lib.gettokens import GetTokensWindow
406
407        self._auxiliary_window_nav(
408            win_type=GetTokensWindow,
409            win_create_call=lambda: GetTokensWindow(
410                origin_widget=bauiv1.get_special_widget('get_tokens_button')
411            ),
412        )
413
414    def _root_ui_chest_slot_pressed(self, index: int) -> None:
415        print(f'CHEST {index} PRESSED')
416        screenmessage('UNDER CONSTRUCTION.')

AppMode for the classic BombSquad experience.

@override
@classmethod
def get_app_experience(cls) -> bacommon.app.AppExperience:
42    @override
43    @classmethod
44    def get_app_experience(cls) -> AppExperience:
45        return AppExperience.MELEE

Return the overall experience provided by this mode.

@override
def handle_intent(self, intent: babase.AppIntent) -> None:
53    @override
54    def handle_intent(self, intent: AppIntent) -> None:
55        if isinstance(intent, AppIntentExec):
56            _baclassic.classic_app_mode_handle_app_intent_exec(intent.code)
57            return
58        assert isinstance(intent, AppIntentDefault)
59        _baclassic.classic_app_mode_handle_app_intent_default()

Handle an intent.

@override
def on_activate(self) -> None:
 61    @override
 62    def on_activate(self) -> None:
 63
 64        # Let the native layer do its thing.
 65        _baclassic.classic_app_mode_activate()
 66
 67        assert app.plus is not None
 68
 69        # Wire up the root ui to do what we want.
 70        ui = app.ui_v1
 71        ui.root_ui_calls[ui.RootUIElement.ACCOUNT_BUTTON] = (
 72            self._root_ui_account_press
 73        )
 74        ui.root_ui_calls[ui.RootUIElement.MENU_BUTTON] = (
 75            self._root_ui_menu_press
 76        )
 77        ui.root_ui_calls[ui.RootUIElement.SQUAD_BUTTON] = (
 78            self._root_ui_squad_press
 79        )
 80        ui.root_ui_calls[ui.RootUIElement.SETTINGS_BUTTON] = (
 81            self._root_ui_settings_press
 82        )
 83        ui.root_ui_calls[ui.RootUIElement.STORE_BUTTON] = (
 84            self._root_ui_store_press
 85        )
 86        ui.root_ui_calls[ui.RootUIElement.INVENTORY_BUTTON] = (
 87            self._root_ui_inventory_press
 88        )
 89        ui.root_ui_calls[ui.RootUIElement.GET_TOKENS_BUTTON] = (
 90            self._root_ui_get_tokens_press
 91        )
 92        ui.root_ui_calls[ui.RootUIElement.INBOX_BUTTON] = (
 93            self._root_ui_inbox_press
 94        )
 95        ui.root_ui_calls[ui.RootUIElement.TICKETS_METER] = (
 96            self._root_ui_tickets_meter_press
 97        )
 98        ui.root_ui_calls[ui.RootUIElement.TOKENS_METER] = (
 99            self._root_ui_tokens_meter_press
100        )
101        ui.root_ui_calls[ui.RootUIElement.TROPHY_METER] = (
102            self._root_ui_trophy_meter_press
103        )
104        ui.root_ui_calls[ui.RootUIElement.LEVEL_METER] = (
105            self._root_ui_level_meter_press
106        )
107        ui.root_ui_calls[ui.RootUIElement.ACHIEVEMENTS_BUTTON] = (
108            self._root_ui_achievements_press
109        )
110        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_1] = partial(
111            self._root_ui_chest_slot_pressed, 1
112        )
113        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_2] = partial(
114            self._root_ui_chest_slot_pressed, 2
115        )
116        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_3] = partial(
117            self._root_ui_chest_slot_pressed, 3
118        )
119        ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_4] = partial(
120            self._root_ui_chest_slot_pressed, 4
121        )
122
123        # We want to be informed when primary account changes.
124        self._on_primary_account_changed_callback = (
125            app.plus.accounts.on_primary_account_changed_callbacks.add(
126                self.update_for_primary_account
127            )
128        )
129        # Establish subscriptions/etc. for any current primary account.
130        self.update_for_primary_account(app.plus.accounts.primary)

Called when the mode is being activated.

@override
def on_deactivate(self) -> None:
132    @override
133    def on_deactivate(self) -> None:
134
135        # Stop being informed of account changes.
136        self._on_primary_account_changed_callback = None
137
138        # Remove any listeners for any current primary account.
139        self.update_for_primary_account(None)
140
141        # Save where we were in the UI so we return there next time.
142        if app.classic is not None:
143            app.classic.save_ui_state()
144
145        # Let the native layer do its thing.
146        _baclassic.classic_app_mode_deactivate()

Called when the mode is being deactivated.

@override
def on_app_active_changed(self) -> None:
148    @override
149    def on_app_active_changed(self) -> None:
150        # If we've gone inactive, bring up the main menu, which has the
151        # side effect of pausing the action (when possible).
152        if not app.active:
153            invoke_main_menu()

Called when ba*.app.active changes while this mode is active.

The app-mode may want to take action such as pausing a running game in such cases.

def update_for_primary_account(self, account: babase.AccountV2Handle | None) -> None:
155    def update_for_primary_account(
156        self, account: AccountV2Handle | None
157    ) -> None:
158        """Update subscriptions/etc. for a new primary account state."""
159        assert in_logic_thread()
160
161        # Test subscription.
162        if bool(False):
163            assert app.plus is not None
164            if account is None:
165                self._test_sub = None
166            else:
167                with account:
168                    self._test_sub = app.plus.cloud.subscribe_test(
169                        self._on_sub_test_update
170                    )
171        else:
172            self._test_sub = None

Update subscriptions/etc. for a new primary account state.

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

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
special_offer: dict | None
ping_thread_count
allow_ticket_purchases: bool
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
134    @property
135    def platform(self) -> str:
136        """Name of the current platform.
137
138        Examples are: 'mac', 'windows', android'.
139        """
140        assert isinstance(self._env['platform'], str)
141        return self._env['platform']

Name of the current platform.

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

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

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

@override
def on_app_loading(self) -> None:
163    @override
164    def on_app_loading(self) -> None:
165        from bascenev1lib.actor import spazappearance
166        from bascenev1lib import maps as stdmaps
167
168        plus = babase.app.plus
169        assert plus is not None
170
171        env = babase.app.env
172        cfg = babase.app.config
173
174        self.music.on_app_loading()
175
176        # Non-test, non-debug builds should generally be blessed; warn if not.
177        # (so I don't accidentally release a build that can't play tourneys)
178        if not env.debug and not env.test and not plus.is_blessed():
179            babase.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0))
180
181        # FIXME: This should not be hard-coded.
182        for maptype in [
183            stdmaps.HockeyStadium,
184            stdmaps.FootballStadium,
185            stdmaps.Bridgit,
186            stdmaps.BigG,
187            stdmaps.Roundabout,
188            stdmaps.MonkeyFace,
189            stdmaps.ZigZag,
190            stdmaps.ThePad,
191            stdmaps.DoomShroom,
192            stdmaps.LakeFrigid,
193            stdmaps.TipTop,
194            stdmaps.CragCastle,
195            stdmaps.TowerD,
196            stdmaps.HappyThoughts,
197            stdmaps.StepRightUp,
198            stdmaps.Courtyard,
199            stdmaps.Rampage,
200        ]:
201            bascenev1.register_map(maptype)
202
203        spazappearance.register_appearances()
204        bascenev1.init_campaigns()
205
206        launch_count = cfg.get('launchCount', 0)
207        launch_count += 1
208
209        # So we know how many times we've run the game at various
210        # version milestones.
211        for key in ('lc14173', 'lc14292'):
212            cfg.setdefault(key, launch_count)
213
214        cfg['launchCount'] = launch_count
215        cfg.commit()
216
217        # If there's a leftover log file, attempt to upload it to the
218        # master-server and/or get rid of it.
219        babase.handle_leftover_v1_cloud_log_file()
220
221        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:
223    @override
224    def on_app_suspend(self) -> None:
225        self.accounts.on_app_suspend()

Called when the app enters the suspended state.

@override
def on_app_unsuspend(self) -> None:
227    @override
228    def on_app_unsuspend(self) -> None:
229        self.accounts.on_app_unsuspend()
230        self.music.on_app_unsuspend()

Called when the app exits the suspended state.

@override
def on_app_shutdown(self) -> None:
232    @override
233    def on_app_shutdown(self) -> None:
234        self.music.on_app_shutdown()

Called when the app begins shutting down.

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

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

Attempt to cleanly get back to the main menu.

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

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:
457    @classmethod
458    def json_prep(cls, data: Any) -> Any:
459        """Return a json-friendly version of the provided data.
460
461        This converts any tuples to lists and any bytes to strings
462        (interpreted as utf-8, ignoring errors). Logs errors (just once)
463        if any data is modified/discarded/unsupported.
464        """
465
466        if isinstance(data, dict):
467            return dict(
468                (cls.json_prep(key), cls.json_prep(value))
469                for key, value in list(data.items())
470            )
471        if isinstance(data, list):
472            return [cls.json_prep(element) for element in data]
473        if isinstance(data, tuple):
474            logging.exception('json_prep encountered tuple')
475            return [cls.json_prep(element) for element in data]
476        if isinstance(data, bytes):
477            try:
478                return data.decode(errors='ignore')
479            except Exception:
480                logging.exception('json_prep encountered utf-8 decode error')
481                return data.decode(errors='ignore')
482        if not isinstance(data, (str, float, bool, type(None), int)):
483            logging.exception(
484                'got unsupported type in json_prep: %s', type(data)
485            )
486        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:
488    def master_server_v1_get(
489        self,
490        request: str,
491        data: dict[str, Any],
492        callback: MasterServerCallback | None = None,
493        response_type: MasterServerResponseType = MasterServerResponseType.JSON,
494    ) -> None:
495        """Make a call to the master server via a http GET."""
496
497        MasterServerV1CallThread(
498            request, 'get', data, callback, response_type
499        ).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:
501    def master_server_v1_post(
502        self,
503        request: str,
504        data: dict[str, Any],
505        callback: MasterServerCallback | None = None,
506        response_type: MasterServerResponseType = MasterServerResponseType.JSON,
507    ) -> None:
508        """Make a call to the master server via a http POST."""
509        MasterServerV1CallThread(
510            request, 'post', data, callback, response_type
511        ).start()

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

def get_tournament_prize_strings(self, entry: dict[str, typing.Any]) -> list[str]:
513    def get_tournament_prize_strings(self, entry: dict[str, Any]) -> list[str]:
514        """Given a tournament entry, return strings for its prize levels."""
515        from baclassic import _tournament
516
517        return _tournament.get_tournament_prize_strings(entry)

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

def getcampaign(self, name: str) -> bascenev1.Campaign:
519    def getcampaign(self, name: str) -> bascenev1.Campaign:
520        """Return a campaign by name."""
521        return self.campaigns[name]

Return a campaign by name.

def get_next_tip(self) -> str:
523    def get_next_tip(self) -> str:
524        """Returns the next tip to be displayed."""
525        if not self.tips:
526            for tip in get_all_tips():
527                self.tips.insert(random.randint(0, len(self.tips)), tip)
528        tip = self.tips.pop()
529        return tip

Returns the next tip to be displayed.

def run_cpu_benchmark(self) -> None:
531    def run_cpu_benchmark(self) -> None:
532        """Kick off a benchmark to test cpu speeds."""
533        from baclassic._benchmark import run_cpu_benchmark
534
535        run_cpu_benchmark()

Kick off a benchmark to test cpu speeds.

def run_media_reload_benchmark(self) -> None:
537    def run_media_reload_benchmark(self) -> None:
538        """Kick off a benchmark to test media reloading speeds."""
539        from baclassic._benchmark import run_media_reload_benchmark
540
541        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:
543    def run_stress_test(
544        self,
545        *,
546        playlist_type: str = 'Random',
547        playlist_name: str = '__default__',
548        player_count: int = 8,
549        round_duration: int = 30,
550        attract_mode: bool = False,
551    ) -> None:
552        """Run a stress test."""
553        from baclassic._benchmark import run_stress_test
554
555        run_stress_test(
556            playlist_type=playlist_type,
557            playlist_name=playlist_name,
558            player_count=player_count,
559            round_duration=round_duration,
560            attract_mode=attract_mode,
561        )

Run a stress test.

def get_input_device_mapped_value( self, device: _bascenev1.InputDevice, name: str, default: bool = False) -> Any:
563    def get_input_device_mapped_value(
564        self,
565        device: bascenev1.InputDevice,
566        name: str,
567        default: bool = False,
568    ) -> Any:
569        """Return a mapped value for an input device.
570
571        This checks the user config and falls back to default values
572        where available.
573        """
574        return _input.get_input_device_mapped_value(
575            device.name, device.unique_identifier, name, default
576        )

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:
578    def get_input_device_map_hash(
579        self, inputdevice: bascenev1.InputDevice
580    ) -> str:
581        """Given an input device, return hash based on its raw input values."""
582        del inputdevice  # unused currently
583        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]:
585    def get_input_device_config(
586        self, inputdevice: bascenev1.InputDevice, default: bool
587    ) -> tuple[dict, str]:
588        """Given an input device, return its config dict in the app config.
589
590        The dict will be created if it does not exist.
591        """
592        return _input.get_input_device_config(
593            inputdevice.name, inputdevice.unique_identifier, default
594        )

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

Return user-selectable player colors.

def get_player_profile_icon(self, profilename: str) -> str:
600    def get_player_profile_icon(self, profilename: str) -> str:
601        """Given a profile name, returns an icon string for it.
602
603        (non-account profiles only)
604        """
605        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]]:
607    def get_player_profile_colors(
608        self,
609        profilename: str | None,
610        profiles: dict[str, dict[str, Any]] | None = None,
611    ) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
612        """Given a profile, return colors for them."""
613        return bascenev1.get_player_profile_colors(profilename, profiles)

Given a profile, return colors for them.

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

Preload media needed for map preview UIs.

Category: Asset Functions

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

Store our current place in the UI.

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

Bring up main menu ui.

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

Represents attributes and state for an individual achievement.

Category: App Classes

Achievement( name: str, icon_name: str, icon_color: Sequence[float], level_name: str, award: int, hard_mode_only: bool = False)
652    def __init__(
653        self,
654        name: str,
655        icon_name: str,
656        icon_color: Sequence[float],
657        level_name: str,
658        award: int,
659        hard_mode_only: bool = False,
660    ):
661        # pylint: disable=too-many-positional-arguments
662        self._name = name
663        self._icon_name = icon_name
664        self._icon_color: Sequence[float] = list(icon_color) + [1]
665        self._level_name = level_name
666        self._completion_banner_slot: int | None = None
667        self._award = award
668        self._hard_mode_only = hard_mode_only
name: str
670    @property
671    def name(self) -> str:
672        """The name of this achievement."""
673        return self._name

The name of this achievement.

level_name: str
675    @property
676    def level_name(self) -> str:
677        """The name of the level this achievement applies to."""
678        return self._level_name

The name of the level this achievement applies to.

def get_icon_ui_texture(self, complete: bool) -> _bauiv1.Texture:
680    def get_icon_ui_texture(self, complete: bool) -> bauiv1.Texture:
681        """Return the icon texture to display for this achievement"""
682        return bauiv1.gettexture(
683            self._icon_name if complete else 'achievementEmpty'
684        )

Return the icon texture to display for this achievement

def get_icon_texture(self, complete: bool) -> _bascenev1.Texture:
686    def get_icon_texture(self, complete: bool) -> bascenev1.Texture:
687        """Return the icon texture to display for this achievement"""
688        return bascenev1.gettexture(
689            self._icon_name if complete else 'achievementEmpty'
690        )

Return the icon texture to display for this achievement

def get_icon_color(self, complete: bool) -> Sequence[float]:
692    def get_icon_color(self, complete: bool) -> Sequence[float]:
693        """Return the color tint for this Achievement's icon."""
694        if complete:
695            return self._icon_color
696        return 1.0, 1.0, 1.0, 0.6

Return the color tint for this Achievement's icon.

hard_mode_only: bool
698    @property
699    def hard_mode_only(self) -> bool:
700        """Whether this Achievement is only unlockable in hard-mode."""
701        return self._hard_mode_only

Whether this Achievement is only unlockable in hard-mode.

complete: bool
703    @property
704    def complete(self) -> bool:
705        """Whether this Achievement is currently complete."""
706        val: bool = self._getconfig()['Complete']
707        assert isinstance(val, bool)
708        return val

Whether this Achievement is currently complete.

def announce_completion(self, sound: bool = True) -> None:
710    def announce_completion(self, sound: bool = True) -> None:
711        """Kick off an announcement for this achievement's completion."""
712
713        app = babase.app
714        plus = app.plus
715        classic = app.classic
716        if plus is None or classic is None:
717            logging.warning('ach account_completion not available.')
718            return
719
720        ach_ss = classic.ach
721
722        # Even though there are technically achievements when we're not
723        # signed in, lets not show them (otherwise we tend to get
724        # confusing 'controller connected' achievements popping up while
725        # waiting to sign in which can be confusing).
726        if plus.get_v1_account_state() != 'signed_in':
727            return
728
729        # If we're being freshly complete, display/report it and whatnot.
730        if (self, sound) not in ach_ss.achievements_to_display:
731            ach_ss.achievements_to_display.append((self, sound))
732
733        # If there's no achievement display timer going, kick one off
734        # (if one's already running it will pick this up before it dies).
735
736        # Need to check last time too; its possible our timer wasn't able to
737        # clear itself if an activity died and took it down with it.
738        if (
739            ach_ss.achievement_display_timer is None
740            or babase.apptime() - ach_ss.last_achievement_display_time > 2.0
741        ) and bascenev1.getactivity(doraise=False) is not None:
742            ach_ss.achievement_display_timer = bascenev1.BaseTimer(
743                1.0, _display_next_achievement, repeat=True
744            )
745
746            # Show the first immediately.
747            _display_next_achievement()

Kick off an announcement for this achievement's completion.

def set_complete(self, complete: bool = True) -> None:
749    def set_complete(self, complete: bool = True) -> None:
750        """Set an achievement's completed state.
751
752        note this only sets local state; use a transaction to
753        actually award achievements.
754        """
755        config = self._getconfig()
756        if complete != config['Complete']:
757            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
759    @property
760    def display_name(self) -> babase.Lstr:
761        """Return a babase.Lstr for this Achievement's name."""
762        name: babase.Lstr | str
763        try:
764            if self._level_name != '':
765                campaignname, campaign_level = self._level_name.split(':')
766                classic = babase.app.classic
767                assert classic is not None
768                name = (
769                    classic.getcampaign(campaignname)
770                    .getlevel(campaign_level)
771                    .displayname
772                )
773            else:
774                name = ''
775        except Exception:
776            name = ''
777            logging.exception('Error calcing achievement display-name.')
778        return babase.Lstr(
779            resource='achievements.' + self._name + '.name',
780            subs=[('${LEVEL}', name)],
781        )

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

description: babase.Lstr
783    @property
784    def description(self) -> babase.Lstr:
785        """Get a babase.Lstr for the Achievement's brief description."""
786        if (
787            'description'
788            in babase.app.lang.get_resource('achievements')[self._name]
789        ):
790            return babase.Lstr(
791                resource='achievements.' + self._name + '.description'
792            )
793        return babase.Lstr(
794            resource='achievements.' + self._name + '.descriptionFull'
795        )

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

description_complete: babase.Lstr
797    @property
798    def description_complete(self) -> babase.Lstr:
799        """Get a babase.Lstr for the Achievement's description when complete."""
800        if (
801            'descriptionComplete'
802            in babase.app.lang.get_resource('achievements')[self._name]
803        ):
804            return babase.Lstr(
805                resource='achievements.' + self._name + '.descriptionComplete'
806            )
807        return babase.Lstr(
808            resource='achievements.' + self._name + '.descriptionFullComplete'
809        )

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

description_full: babase.Lstr
811    @property
812    def description_full(self) -> babase.Lstr:
813        """Get a babase.Lstr for the Achievement's full description."""
814        return babase.Lstr(
815            resource='achievements.' + self._name + '.descriptionFull',
816            subs=[
817                (
818                    '${LEVEL}',
819                    babase.Lstr(
820                        translate=(
821                            'coopLevelNames',
822                            ACH_LEVEL_NAMES.get(self._name, '?'),
823                        )
824                    ),
825                )
826            ],
827        )

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

description_full_complete: babase.Lstr
829    @property
830    def description_full_complete(self) -> babase.Lstr:
831        """Get a babase.Lstr for the Achievement's full desc. when completed."""
832        return babase.Lstr(
833            resource='achievements.' + self._name + '.descriptionFullComplete',
834            subs=[
835                (
836                    '${LEVEL}',
837                    babase.Lstr(
838                        translate=(
839                            'coopLevelNames',
840                            ACH_LEVEL_NAMES.get(self._name, '?'),
841                        )
842                    ),
843                )
844            ],
845        )

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

def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int:
847    def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int:
848        """Get the ticket award value for this achievement."""
849        plus = babase.app.plus
850        if plus is None:
851            return 0
852        val: int = plus.get_v1_account_misc_read_val(
853            'achAward.' + self._name, self._award
854        ) * _get_ach_mult(include_pro_bonus)
855        assert isinstance(val, int)
856        return val

Get the ticket award value for this achievement.

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

Create a display for the Achievement.

Shows the Achievement icon, name, and description.

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

Create the banner/sound for an acquired achievement announcement.

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

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

def get_achievement(self, name: str) -> Achievement:
574    def get_achievement(self, name: str) -> Achievement:
575        """Return an Achievement by name."""
576        achs = [a for a in self.achievements if a.name == name]
577        assert len(achs) < 2
578        if not achs:
579            raise ValueError("Invalid achievement name: '" + name + "'")
580        return achs[0]

Return an Achievement by name.

def achievements_for_coop_level(self, level_name: str) -> list[Achievement]:
582    def achievements_for_coop_level(self, level_name: str) -> list[Achievement]:
583        """Given a level name, return achievements available for it."""
584
585        # For the Easy campaign we return achievements for the Default
586        # campaign too. (want the user to see what achievements are part of the
587        # level even if they can't unlock them all on easy mode).
588        return [
589            a
590            for a in self.achievements
591            if a.level_name
592            in (level_name, level_name.replace('Easy', 'Default'))
593        ]

Given a level name, return achievements available for it.