baclassic

Classic ballistica components.

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

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

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

 1# Released under the MIT License. See LICENSE for details.
 2#
 3"""Classic ballistica components.
 4
 5This package is used as a 'dumping ground' for functionality that is
 6necessary to keep legacy parts of the app working, but which may no
 7longer be the 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
12ClassicSubsystem. 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 8
20
21# Note: Code relying on classic should import things from here *only*
22# for type-checking and use the versions in app.classic at runtime; that
23# way type-checking will cleanly cover the classic-not-present case
24# (app.classic being None).
25import logging
26
27from baclassic._subsystem import ClassicSubsystem
28from baclassic._achievement import Achievement, AchievementSubsystem
29
30__all__ = [
31    'ClassicSubsystem',
32    'Achievement',
33    'AchievementSubsystem',
34]
35
36# Sanity check: we want to keep ballistica's dependencies and
37# bootstrapping order clearly defined; let's check a few particular
38# modules to make sure they never directly or indirectly import us
39# before their own execs complete.
40if __debug__:
41    for _mdl in 'babase', '_babase':
42        if not hasattr(__import__(_mdl), '_REACHED_END_OF_MODULE'):
43            logging.warning(
44                '%s was imported before %s finished importing;'
45                ' should not happen.',
46                __name__,
47                _mdl,
48            )
class ClassicSubsystem(babase._appsubsystem.AppSubsystem):
 38class ClassicSubsystem(babase.AppSubsystem):
 39    """Subsystem for classic functionality in the app.
 40
 41    The single shared instance of this app can be accessed at
 42    babase.app.classic. Note that it is possible for babase.app.classic to
 43    be None if the classic package is not present, and code should handle
 44    that case gracefully.
 45    """
 46
 47    # pylint: disable=too-many-public-methods
 48
 49    # noinspection PyUnresolvedReferences
 50    from baclassic._music import MusicPlayMode
 51
 52    def __init__(self) -> None:
 53        super().__init__()
 54        self._env = babase.env()
 55
 56        self.accounts = AccountV1Subsystem()
 57        self.ads = AdsSubsystem()
 58        self.ach = AchievementSubsystem()
 59        self.store = StoreSubsystem()
 60        self.music = MusicSubsystem()
 61
 62        # Co-op Campaigns.
 63        self.campaigns: dict[str, bascenev1.Campaign] = {}
 64        self.custom_coop_practice_games: list[str] = []
 65
 66        # Lobby.
 67        self.lobby_random_profile_index: int = 1
 68        self.lobby_random_char_index_offset = random.randrange(1000)
 69        self.lobby_account_profile_device_id: int | None = None
 70
 71        # Misc.
 72        self.tips: list[str] = []
 73        self.stress_test_reset_timer: 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
106        self.ffa_series_length = 24
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.delegate: AppDelegate | None = None
115        self.party_window: weakref.ref[PartyWindow] | None = None
116
117        # Store.
118        self.store_layout: dict[str, list[dict[str, Any]]] | None = None
119        self.store_items: dict[str, dict] | None = None
120        self.pro_sale_start_time: int | None = None
121        self.pro_sale_start_val: int | None = None
122
123    @property
124    def platform(self) -> str:
125        """Name of the current platform.
126
127        Examples are: 'mac', 'windows', android'.
128        """
129        assert isinstance(self._env['platform'], str)
130        return self._env['platform']
131
132    def scene_v1_protocol_version(self) -> int:
133        """(internal)"""
134        return bascenev1.protocol_version()
135
136    @property
137    def subplatform(self) -> str:
138        """String for subplatform.
139
140        Can be empty. For the 'android' platform, subplatform may
141        be 'google', 'amazon', etc.
142        """
143        assert isinstance(self._env['subplatform'], str)
144        return self._env['subplatform']
145
146    @property
147    def legacy_user_agent_string(self) -> str:
148        """String containing various bits of info about OS/device/etc."""
149        assert isinstance(self._env['legacy_user_agent_string'], str)
150        return self._env['legacy_user_agent_string']
151
152    def on_app_loading(self) -> None:
153        from bascenev1lib.actor import spazappearance
154        from bascenev1lib import maps as stdmaps
155
156        from baclassic._appdelegate import AppDelegate
157
158        plus = babase.app.plus
159        assert plus is not None
160
161        env = babase.app.env
162        cfg = babase.app.config
163
164        self.music.on_app_loading()
165
166        self.delegate = AppDelegate()
167
168        # Non-test, non-debug builds should generally be blessed; warn if not.
169        # (so I don't accidentally release a build that can't play tourneys)
170        if not env.debug and not env.test and not plus.is_blessed():
171            babase.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0))
172
173        # FIXME: This should not be hard-coded.
174        for maptype in [
175            stdmaps.HockeyStadium,
176            stdmaps.FootballStadium,
177            stdmaps.Bridgit,
178            stdmaps.BigG,
179            stdmaps.Roundabout,
180            stdmaps.MonkeyFace,
181            stdmaps.ZigZag,
182            stdmaps.ThePad,
183            stdmaps.DoomShroom,
184            stdmaps.LakeFrigid,
185            stdmaps.TipTop,
186            stdmaps.CragCastle,
187            stdmaps.TowerD,
188            stdmaps.HappyThoughts,
189            stdmaps.StepRightUp,
190            stdmaps.Courtyard,
191            stdmaps.Rampage,
192        ]:
193            bascenev1.register_map(maptype)
194
195        spazappearance.register_appearances()
196        bascenev1.init_campaigns()
197
198        launch_count = cfg.get('launchCount', 0)
199        launch_count += 1
200
201        # So we know how many times we've run the game at various
202        # version milestones.
203        for key in ('lc14173', 'lc14292'):
204            cfg.setdefault(key, launch_count)
205
206        cfg['launchCount'] = launch_count
207        cfg.commit()
208
209        # Run a test in a few seconds to see if we should pop up an existing
210        # pending special offer.
211        def check_special_offer() -> None:
212            assert plus is not None
213
214            from bauiv1lib.specialoffer import show_offer
215
216            if (
217                'pendingSpecialOffer' in cfg
218                and plus.get_v1_account_public_login_id()
219                == cfg['pendingSpecialOffer']['a']
220            ):
221                self.special_offer = cfg['pendingSpecialOffer']['o']
222                show_offer()
223
224        if babase.app.env.gui:
225            babase.apptimer(3.0, check_special_offer)
226
227        # If there's a leftover log file, attempt to upload it to the
228        # master-server and/or get rid of it.
229        babase.handle_leftover_v1_cloud_log_file()
230
231        self.accounts.on_app_loading()
232
233    def on_app_pause(self) -> None:
234        self.accounts.on_app_pause()
235
236    def on_app_resume(self) -> None:
237        self.accounts.on_app_resume()
238        self.music.on_app_resume()
239
240    def on_app_shutdown(self) -> None:
241        self.music.on_app_shutdown()
242
243    def pause(self) -> None:
244        """Pause the game due to a user request or menu popping up.
245
246        If there's a foreground host-activity that says it's pausable, tell it
247        to pause. Note: we now no longer pause if there are connected clients.
248        """
249        activity: bascenev1.Activity | None = (
250            bascenev1.get_foreground_host_activity()
251        )
252        if (
253            activity is not None
254            and activity.allow_pausing
255            and not bascenev1.have_connected_clients()
256        ):
257            from babase import Lstr
258            from bascenev1 import NodeActor
259
260            # FIXME: Shouldn't be touching scene stuff here;
261            #  should just pass the request on to the host-session.
262            with activity.context:
263                globs = activity.globalsnode
264                if not globs.paused:
265                    bascenev1.getsound('refWhistle').play()
266                    globs.paused = True
267
268                # FIXME: This should not be an attr on Actor.
269                activity.paused_text = NodeActor(
270                    bascenev1.newnode(
271                        'text',
272                        attrs={
273                            'text': Lstr(resource='pausedByHostText'),
274                            'client_only': True,
275                            'flatness': 1.0,
276                            'h_align': 'center',
277                        },
278                    )
279                )
280
281    def resume(self) -> None:
282        """Resume the game due to a user request or menu closing.
283
284        If there's a foreground host-activity that's currently paused, tell it
285        to resume.
286        """
287
288        # FIXME: Shouldn't be touching scene stuff here;
289        #  should just pass the request on to the host-session.
290        activity = bascenev1.get_foreground_host_activity()
291        if activity is not None:
292            with activity.context:
293                globs = activity.globalsnode
294                if globs.paused:
295                    bascenev1.getsound('refWhistle').play()
296                    globs.paused = False
297
298                    # FIXME: This should not be an actor attr.
299                    activity.paused_text = None
300
301    def add_coop_practice_level(self, level: bascenev1.Level) -> None:
302        """Adds an individual level to the 'practice' section in Co-op."""
303
304        # Assign this level to our catch-all campaign.
305        self.campaigns['Challenges'].addlevel(level)
306
307        # Make note to add it to our challenges UI.
308        self.custom_coop_practice_games.append(f'Challenges:{level.name}')
309
310    def launch_coop_game(
311        self, game: str, force: bool = False, args: dict | None = None
312    ) -> bool:
313        """High level way to launch a local co-op session."""
314        # pylint: disable=cyclic-import
315        from bauiv1lib.coop.level import CoopLevelLockedWindow
316
317        assert babase.app.classic is not None
318
319        if args is None:
320            args = {}
321        if game == '':
322            raise ValueError('empty game name')
323        campaignname, levelname = game.split(':')
324        campaign = babase.app.classic.getcampaign(campaignname)
325
326        # If this campaign is sequential, make sure we've completed the
327        # one before this.
328        if campaign.sequential and not force:
329            for level in campaign.levels:
330                if level.name == levelname:
331                    break
332                if not level.complete:
333                    CoopLevelLockedWindow(
334                        campaign.getlevel(levelname).displayname,
335                        campaign.getlevel(level.name).displayname,
336                    )
337                    return False
338
339        # Ok, we're good to go.
340        self.coop_session_args = {
341            'campaign': campaignname,
342            'level': levelname,
343        }
344        for arg_name, arg_val in list(args.items()):
345            self.coop_session_args[arg_name] = arg_val
346
347        def _fade_end() -> None:
348            from bascenev1 import CoopSession
349
350            try:
351                bascenev1.new_host_session(CoopSession)
352            except Exception:
353                logging.exception('Error creating coopsession after fade end.')
354                from bascenev1lib.mainmenu import MainMenuSession
355
356                bascenev1.new_host_session(MainMenuSession)
357
358        babase.fade_screen(False, endcall=_fade_end)
359        return True
360
361    def return_to_main_menu_session_gracefully(
362        self, reset_ui: bool = True
363    ) -> None:
364        """Attempt to cleanly get back to the main menu."""
365        # pylint: disable=cyclic-import
366        from baclassic import _benchmark
367        from bascenev1lib.mainmenu import MainMenuSession
368
369        plus = babase.app.plus
370        assert plus is not None
371
372        if reset_ui:
373            babase.app.ui_v1.clear_main_menu_window()
374
375        if isinstance(bascenev1.get_foreground_host_session(), MainMenuSession):
376            # It may be possible we're on the main menu but the screen is faded
377            # so fade back in.
378            babase.fade_screen(True)
379            return
380
381        _benchmark.stop_stress_test()  # Stop stress-test if in progress.
382
383        # If we're in a host-session, tell them to end.
384        # This lets them tear themselves down gracefully.
385        host_session: bascenev1.Session | None = (
386            bascenev1.get_foreground_host_session()
387        )
388        if host_session is not None:
389            # Kick off a little transaction so we'll hopefully have all the
390            # latest account state when we get back to the menu.
391            plus.add_v1_account_transaction(
392                {'type': 'END_SESSION', 'sType': str(type(host_session))}
393            )
394            plus.run_v1_account_transactions()
395
396            host_session.end()
397
398        # Otherwise just force the issue.
399        else:
400            babase.pushcall(
401                babase.Call(bascenev1.new_host_session, MainMenuSession)
402            )
403
404    def getmaps(self, playtype: str) -> list[str]:
405        """Return a list of bascenev1.Map types supporting a playtype str.
406
407        Category: **Asset Functions**
408
409        Maps supporting a given playtype must provide a particular set of
410        features and lend themselves to a certain style of play.
411
412        Play Types:
413
414        'melee'
415          General fighting map.
416          Has one or more 'spawn' locations.
417
418        'team_flag'
419          For games such as Capture The Flag where each team spawns by a flag.
420          Has two or more 'spawn' locations, each with a corresponding 'flag'
421          location (based on index).
422
423        'single_flag'
424          For games such as King of the Hill or Keep Away where multiple teams
425          are fighting over a single flag.
426          Has two or more 'spawn' locations and 1 'flag_default' location.
427
428        'conquest'
429          For games such as Conquest where flags are spread throughout the map
430          - has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations.
431
432        'king_of_the_hill' - has 2+ 'spawn' locations,
433           1+ 'flag_default' locations, and 1+ 'powerup_spawn' locations
434
435        'hockey'
436          For hockey games.
437          Has two 'goal' locations, corresponding 'spawn' locations, and one
438          'flag_default' location (for where puck spawns)
439
440        'football'
441          For football games.
442          Has two 'goal' locations, corresponding 'spawn' locations, and one
443          'flag_default' location (for where flag/ball/etc. spawns)
444
445        'race'
446          For racing games where players much touch each region in order.
447          Has two or more 'race_point' locations.
448        """
449        return sorted(
450            key
451            for key, val in self.maps.items()
452            if playtype in val.get_play_types()
453        )
454
455    def show_online_score_ui(
456        self,
457        show: str = 'general',
458        game: str | None = None,
459        game_version: str | None = None,
460    ) -> None:
461        """(internal)"""
462        bauiv1.show_online_score_ui(show, game, game_version)
463
464    def game_begin_analytics(self) -> None:
465        """(internal)"""
466        from baclassic import _analytics
467
468        _analytics.game_begin_analytics()
469
470    @classmethod
471    def json_prep(cls, data: Any) -> Any:
472        """Return a json-friendly version of the provided data.
473
474        This converts any tuples to lists and any bytes to strings
475        (interpreted as utf-8, ignoring errors). Logs errors (just once)
476        if any data is modified/discarded/unsupported.
477        """
478
479        if isinstance(data, dict):
480            return dict(
481                (cls.json_prep(key), cls.json_prep(value))
482                for key, value in list(data.items())
483            )
484        if isinstance(data, list):
485            return [cls.json_prep(element) for element in data]
486        if isinstance(data, tuple):
487            logging.exception('json_prep encountered tuple')
488            return [cls.json_prep(element) for element in data]
489        if isinstance(data, bytes):
490            try:
491                return data.decode(errors='ignore')
492            except Exception:
493                logging.exception('json_prep encountered utf-8 decode error')
494                return data.decode(errors='ignore')
495        if not isinstance(data, (str, float, bool, type(None), int)):
496            logging.exception(
497                'got unsupported type in json_prep: %s', type(data)
498            )
499        return data
500
501    def master_server_v1_get(
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 GET."""
509
510        MasterServerV1CallThread(
511            request, 'get', data, callback, response_type
512        ).start()
513
514    def master_server_v1_post(
515        self,
516        request: str,
517        data: dict[str, Any],
518        callback: MasterServerCallback | None = None,
519        response_type: MasterServerResponseType = MasterServerResponseType.JSON,
520    ) -> None:
521        """Make a call to the master server via a http POST."""
522        MasterServerV1CallThread(
523            request, 'post', data, callback, response_type
524        ).start()
525
526    def get_tournament_prize_strings(self, entry: dict[str, Any]) -> list[str]:
527        """Given a tournament entry, return strings for its prize levels."""
528        from baclassic import _tournament
529
530        return _tournament.get_tournament_prize_strings(entry)
531
532    def getcampaign(self, name: str) -> bascenev1.Campaign:
533        """Return a campaign by name."""
534        return self.campaigns[name]
535
536    def get_next_tip(self) -> str:
537        """Returns the next tip to be displayed."""
538        if not self.tips:
539            for tip in get_all_tips():
540                self.tips.insert(random.randint(0, len(self.tips)), tip)
541        tip = self.tips.pop()
542        return tip
543
544    def run_gpu_benchmark(self) -> None:
545        """Kick off a benchmark to test gpu speeds."""
546        from baclassic._benchmark import run_gpu_benchmark as run
547
548        run()
549
550    def run_cpu_benchmark(self) -> None:
551        """Kick off a benchmark to test cpu speeds."""
552        from baclassic._benchmark import run_cpu_benchmark as run
553
554        run()
555
556    def run_media_reload_benchmark(self) -> None:
557        """Kick off a benchmark to test media reloading speeds."""
558        from baclassic._benchmark import run_media_reload_benchmark as run
559
560        run()
561
562    def run_stress_test(
563        self,
564        playlist_type: str = 'Random',
565        playlist_name: str = '__default__',
566        player_count: int = 8,
567        round_duration: int = 30,
568    ) -> None:
569        """Run a stress test."""
570        from baclassic._benchmark import run_stress_test as run
571
572        run(playlist_type, playlist_name, player_count, round_duration)
573
574    def get_input_device_mapped_value(
575        self, device: bascenev1.InputDevice, name: str
576    ) -> Any:
577        """Returns a mapped value for an input device.
578
579        This checks the user config and falls back to default values
580        where available.
581        """
582        return _input.get_input_device_mapped_value(
583            device.name, device.unique_identifier, name
584        )
585
586    def get_input_device_map_hash(
587        self, inputdevice: bascenev1.InputDevice
588    ) -> str:
589        """Given an input device, return hash based on its raw input values."""
590        del inputdevice  # unused currently
591        return _input.get_input_device_map_hash()
592
593    def get_input_device_config(
594        self, inputdevice: bascenev1.InputDevice, default: bool
595    ) -> tuple[dict, str]:
596        """Given an input device, return its config dict in the app config.
597
598        The dict will be created if it does not exist.
599        """
600        return _input.get_input_device_config(
601            inputdevice.name, inputdevice.unique_identifier, default
602        )
603
604    def get_player_colors(self) -> list[tuple[float, float, float]]:
605        """Return user-selectable player colors."""
606        return bascenev1.get_player_colors()
607
608    def get_player_profile_icon(self, profilename: str) -> str:
609        """Given a profile name, returns an icon string for it.
610
611        (non-account profiles only)
612        """
613        return bascenev1.get_player_profile_icon(profilename)
614
615    def get_player_profile_colors(
616        self,
617        profilename: str | None,
618        profiles: dict[str, dict[str, Any]] | None = None,
619    ) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
620        """Given a profile, return colors for them."""
621        return bascenev1.get_player_profile_colors(profilename, profiles)
622
623    def get_foreground_host_session(self) -> bascenev1.Session | None:
624        """(internal)"""
625        return bascenev1.get_foreground_host_session()
626
627    def get_foreground_host_activity(self) -> bascenev1.Activity | None:
628        """(internal)"""
629        return bascenev1.get_foreground_host_activity()
630
631    def show_config_error_window(self) -> bool:
632        """(internal)"""
633        if self.platform in ('mac', 'linux', 'windows'):
634            from bauiv1lib.configerror import ConfigErrorWindow
635
636            babase.pushcall(ConfigErrorWindow)
637            return True
638        return False
639
640    def value_test(
641        self,
642        arg: str,
643        change: float | None = None,
644        absolute: float | None = None,
645    ) -> float:
646        """(internal)"""
647        return _baclassic.value_test(arg, change, absolute)
648
649    def set_master_server_source(self, source: int) -> None:
650        """(internal)"""
651        bascenev1.set_master_server_source(source)
652
653    def get_game_port(self) -> int:
654        """(internal)"""
655        return bascenev1.get_game_port()
656
657    def v2_upgrade_window(self, login_name: str, code: str) -> None:
658        """(internal)"""
659
660        from bauiv1lib.v2upgrade import V2UpgradeWindow
661
662        V2UpgradeWindow(login_name, code)
663
664    def account_link_code_window(self, data: dict[str, Any]) -> None:
665        """(internal)"""
666        from bauiv1lib.account.link import AccountLinkCodeWindow
667
668        AccountLinkCodeWindow(data)
669
670    def server_dialog(self, delay: float, data: dict[str, Any]) -> None:
671        """(internal)"""
672        from bauiv1lib.serverdialog import (
673            ServerDialogData,
674            ServerDialogWindow,
675        )
676
677        try:
678            sddata = dataclass_from_dict(ServerDialogData, data)
679        except Exception:
680            sddata = None
681            logging.warning(
682                'Got malformatted ServerDialogData: %s',
683                data,
684            )
685        if sddata is not None:
686            babase.apptimer(
687                delay,
688                babase.Call(ServerDialogWindow, sddata),
689            )
690
691    def ticket_icon_press(self) -> None:
692        """(internal)"""
693        from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
694
695        ResourceTypeInfoWindow(
696            origin_widget=bauiv1.get_special_widget('tickets_info_button')
697        )
698
699    def show_url_window(self, address: str) -> None:
700        """(internal)"""
701        from bauiv1lib.url import ShowURLWindow
702
703        ShowURLWindow(address)
704
705    def quit_window(self) -> None:
706        """(internal)"""
707        from bauiv1lib.confirm import QuitWindow
708
709        QuitWindow()
710
711    def tournament_entry_window(
712        self,
713        tournament_id: str,
714        tournament_activity: bascenev1.Activity | None = None,
715        position: tuple[float, float] = (0.0, 0.0),
716        delegate: Any = None,
717        scale: float | None = None,
718        offset: tuple[float, float] = (0.0, 0.0),
719        on_close_call: Callable[[], Any] | None = None,
720    ) -> None:
721        """(internal)"""
722        from bauiv1lib.tournamententry import TournamentEntryWindow
723
724        TournamentEntryWindow(
725            tournament_id,
726            tournament_activity,
727            position,
728            delegate,
729            scale,
730            offset,
731            on_close_call,
732        )
733
734    def get_main_menu_session(self) -> type[bascenev1.Session]:
735        """(internal)"""
736        from bascenev1lib.mainmenu import MainMenuSession
737
738        return MainMenuSession
739
740    def continues_window(
741        self,
742        activity: bascenev1.Activity,
743        cost: int,
744        continue_call: Callable[[], Any],
745        cancel_call: Callable[[], Any],
746    ) -> None:
747        """(internal)"""
748        from bauiv1lib.continues import ContinuesWindow
749
750        ContinuesWindow(activity, cost, continue_call, cancel_call)
751
752    def profile_browser_window(
753        self,
754        transition: str = 'in_right',
755        in_main_menu: bool = True,
756        selected_profile: str | None = None,
757        origin_widget: bauiv1.Widget | None = None,
758    ) -> None:
759        """(internal)"""
760        from bauiv1lib.profile.browser import ProfileBrowserWindow
761
762        ProfileBrowserWindow(
763            transition, in_main_menu, selected_profile, origin_widget
764        )
765
766    def preload_map_preview_media(self) -> None:
767        """Preload media needed for map preview UIs.
768
769        Category: **Asset Functions**
770        """
771        try:
772            bauiv1.getmesh('level_select_button_opaque')
773            bauiv1.getmesh('level_select_button_transparent')
774            for maptype in list(self.maps.values()):
775                map_tex_name = maptype.get_preview_texture_name()
776                if map_tex_name is not None:
777                    bauiv1.gettexture(map_tex_name)
778        except Exception:
779            logging.exception('Error preloading map preview media.')
780
781    def party_icon_activate(self, origin: Sequence[float]) -> None:
782        """(internal)"""
783        from bauiv1lib.party import PartyWindow
784        from babase import app
785
786        assert app.env.gui
787
788        bauiv1.getsound('swish').play()
789
790        # If it exists, dismiss it; otherwise make a new one.
791        party_window = (
792            None if self.party_window is None else self.party_window()
793        )
794        if party_window is not None:
795            party_window.close()
796        else:
797            self.party_window = weakref.ref(PartyWindow(origin=origin))
798
799    def device_menu_press(self, device_id: int | None) -> None:
800        """(internal)"""
801        from bauiv1lib.mainmenu import MainMenuWindow
802        from bauiv1 import set_ui_input_device
803
804        assert babase.app is not None
805        in_main_menu = babase.app.ui_v1.has_main_menu_window()
806        if not in_main_menu:
807            set_ui_input_device(device_id)
808
809            if babase.app.env.gui:
810                bauiv1.getsound('swish').play()
811
812            babase.app.ui_v1.set_main_menu_window(
813                MainMenuWindow().get_root_widget()
814            )

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.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_reset_timer: _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.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]
delegate: baclassic._appdelegate.AppDelegate | None
party_window: weakref.ReferenceType[bauiv1lib.party.PartyWindow] | 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

Name of the current platform.

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

subplatform: str

String for subplatform.

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

legacy_user_agent_string: str

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

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

def on_app_pause(self) -> None:
233    def on_app_pause(self) -> None:
234        self.accounts.on_app_pause()

Called when the app enters the paused state.

def on_app_resume(self) -> None:
236    def on_app_resume(self) -> None:
237        self.accounts.on_app_resume()
238        self.music.on_app_resume()

Called when the app exits the paused state.

def on_app_shutdown(self) -> None:
240    def on_app_shutdown(self) -> None:
241        self.music.on_app_shutdown()

Called when the app is shutting down.

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

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

Attempt to cleanly get back to the main menu.

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

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:
470    @classmethod
471    def json_prep(cls, data: Any) -> Any:
472        """Return a json-friendly version of the provided data.
473
474        This converts any tuples to lists and any bytes to strings
475        (interpreted as utf-8, ignoring errors). Logs errors (just once)
476        if any data is modified/discarded/unsupported.
477        """
478
479        if isinstance(data, dict):
480            return dict(
481                (cls.json_prep(key), cls.json_prep(value))
482                for key, value in list(data.items())
483            )
484        if isinstance(data, list):
485            return [cls.json_prep(element) for element in data]
486        if isinstance(data, tuple):
487            logging.exception('json_prep encountered tuple')
488            return [cls.json_prep(element) for element in data]
489        if isinstance(data, bytes):
490            try:
491                return data.decode(errors='ignore')
492            except Exception:
493                logging.exception('json_prep encountered utf-8 decode error')
494                return data.decode(errors='ignore')
495        if not isinstance(data, (str, float, bool, type(None), int)):
496            logging.exception(
497                'got unsupported type in json_prep: %s', type(data)
498            )
499        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: 'MasterServerCallback | None' = None, response_type: baclassic._net.MasterServerResponseType = <MasterServerResponseType.JSON: 0>) -> None:
501    def master_server_v1_get(
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 GET."""
509
510        MasterServerV1CallThread(
511            request, 'get', data, callback, response_type
512        ).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: 'MasterServerCallback | None' = None, response_type: baclassic._net.MasterServerResponseType = <MasterServerResponseType.JSON: 0>) -> None:
514    def master_server_v1_post(
515        self,
516        request: str,
517        data: dict[str, Any],
518        callback: MasterServerCallback | None = None,
519        response_type: MasterServerResponseType = MasterServerResponseType.JSON,
520    ) -> None:
521        """Make a call to the master server via a http POST."""
522        MasterServerV1CallThread(
523            request, 'post', data, callback, response_type
524        ).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]:
526    def get_tournament_prize_strings(self, entry: dict[str, Any]) -> list[str]:
527        """Given a tournament entry, return strings for its prize levels."""
528        from baclassic import _tournament
529
530        return _tournament.get_tournament_prize_strings(entry)

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

def getcampaign(self, name: str) -> bascenev1._campaign.Campaign:
532    def getcampaign(self, name: str) -> bascenev1.Campaign:
533        """Return a campaign by name."""
534        return self.campaigns[name]

Return a campaign by name.

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

Returns the next tip to be displayed.

def run_gpu_benchmark(self) -> None:
544    def run_gpu_benchmark(self) -> None:
545        """Kick off a benchmark to test gpu speeds."""
546        from baclassic._benchmark import run_gpu_benchmark as run
547
548        run()

Kick off a benchmark to test gpu speeds.

def run_cpu_benchmark(self) -> None:
550    def run_cpu_benchmark(self) -> None:
551        """Kick off a benchmark to test cpu speeds."""
552        from baclassic._benchmark import run_cpu_benchmark as run
553
554        run()

Kick off a benchmark to test cpu speeds.

def run_media_reload_benchmark(self) -> None:
556    def run_media_reload_benchmark(self) -> None:
557        """Kick off a benchmark to test media reloading speeds."""
558        from baclassic._benchmark import run_media_reload_benchmark as run
559
560        run()

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) -> None:
562    def run_stress_test(
563        self,
564        playlist_type: str = 'Random',
565        playlist_name: str = '__default__',
566        player_count: int = 8,
567        round_duration: int = 30,
568    ) -> None:
569        """Run a stress test."""
570        from baclassic._benchmark import run_stress_test as run
571
572        run(playlist_type, playlist_name, player_count, round_duration)

Run a stress test.

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

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

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

Return user-selectable player colors.

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

Given a profile, return colors for them.

def preload_map_preview_media(self) -> None:
766    def preload_map_preview_media(self) -> None:
767        """Preload media needed for map preview UIs.
768
769        Category: **Asset Functions**
770        """
771        try:
772            bauiv1.getmesh('level_select_button_opaque')
773            bauiv1.getmesh('level_select_button_transparent')
774            for maptype in list(self.maps.values()):
775                map_tex_name = maptype.get_preview_texture_name()
776                if map_tex_name is not None:
777                    bauiv1.gettexture(map_tex_name)
778        except Exception:
779            logging.exception('Error preloading map preview media.')

Preload media needed for map preview UIs.

Category: Asset Functions

Inherited Members
babase._appsubsystem.AppSubsystem
on_app_running
on_app_shutdown_complete
do_apply_app_config
class ClassicSubsystem.MusicPlayMode(enum.Enum):
21class MusicPlayMode(Enum):
22    """Influences behavior when playing music.
23
24    Category: **Enums**
25    """
26
27    REGULAR = 'regular'
28    TEST = 'test'

Influences behavior when playing music.

Category: Enums

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

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        self._name = name
662        self._icon_name = icon_name
663        self._icon_color: Sequence[float] = list(icon_color) + [1]
664        self._level_name = level_name
665        self._completion_banner_slot: int | None = None
666        self._award = award
667        self._hard_mode_only = hard_mode_only
name: str

The name of this achievement.

level_name: str

The name of the level this achievement applies to.

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

Return the icon texture to display for this achievement

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

Return the icon texture to display for this achievement

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

Return the color tint for this Achievement's icon.

hard_mode_only: bool

Whether this Achievement is only unlockable in hard-mode.

complete: bool

Whether this Achievement is currently complete.

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

Kick off an announcement for this achievement's completion.

def set_complete(self, complete: bool = True) -> None:
748    def set_complete(self, complete: bool = True) -> None:
749        """Set an achievement's completed state.
750
751        note this only sets local state; use a transaction to
752        actually award achievements.
753        """
754        config = self._getconfig()
755        if complete != config['Complete']:
756            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._language.Lstr

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

description: babase._language.Lstr

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

description_complete: babase._language.Lstr

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

description_full: babase._language.Lstr

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

description_full_complete: babase._language.Lstr

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

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

Get the ticket award value for this achievement.

power_ranking_value: int

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

Create a display for the Achievement.

Shows the Achievement icon, name, and description.

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

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: (
 80            list[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.