bastd.ui.coop.browser

UI for browsing available co-op levels/games/etc.

   1# Released under the MIT License. See LICENSE for details.
   2#
   3"""UI for browsing available co-op levels/games/etc."""
   4# FIXME: Break this up.
   5# pylint: disable=too-many-lines
   6
   7from __future__ import annotations
   8
   9from typing import TYPE_CHECKING
  10
  11import ba
  12import ba.internal
  13from bastd.ui.store.button import StoreButton
  14from bastd.ui.league.rankbutton import LeagueRankButton
  15from bastd.ui.store.browser import StoreBrowserWindow
  16
  17if TYPE_CHECKING:
  18    from typing import Any
  19
  20    from bastd.ui.coop.tournamentbutton import TournamentButton
  21
  22
  23class CoopBrowserWindow(ba.Window):
  24    """Window for browsing co-op levels/games/etc."""
  25
  26    def _update_corner_button_positions(self) -> None:
  27        uiscale = ba.app.ui.uiscale
  28        offs = (
  29            -55
  30            if uiscale is ba.UIScale.SMALL
  31            and ba.internal.is_party_icon_visible()
  32            else 0
  33        )
  34        if self._league_rank_button is not None:
  35            self._league_rank_button.set_position(
  36                (
  37                    self._width - 282 + offs - self._x_inset,
  38                    self._height
  39                    - 85
  40                    - (4 if uiscale is ba.UIScale.SMALL else 0),
  41                )
  42            )
  43        if self._store_button is not None:
  44            self._store_button.set_position(
  45                (
  46                    self._width - 170 + offs - self._x_inset,
  47                    self._height
  48                    - 85
  49                    - (4 if uiscale is ba.UIScale.SMALL else 0),
  50                )
  51            )
  52
  53    def __init__(
  54        self,
  55        transition: str | None = 'in_right',
  56        origin_widget: ba.Widget | None = None,
  57    ):
  58        # pylint: disable=too-many-statements
  59        # pylint: disable=cyclic-import
  60        import threading
  61
  62        # Preload some modules we use in a background thread so we won't
  63        # have a visual hitch when the user taps them.
  64        threading.Thread(target=self._preload_modules).start()
  65
  66        ba.set_analytics_screen('Coop Window')
  67
  68        app = ba.app
  69        cfg = app.config
  70
  71        # Quick note to players that tourneys won't work in ballistica
  72        # core builds. (need to split the word so it won't get subbed out)
  73        if 'ballistica' + 'core' == ba.internal.appname():
  74            ba.timer(
  75                1.0,
  76                lambda: ba.screenmessage(
  77                    ba.Lstr(resource='noTournamentsInTestBuildText'),
  78                    color=(1, 1, 0),
  79                ),
  80                timetype=ba.TimeType.REAL,
  81            )
  82
  83        # If they provided an origin-widget, scale up from that.
  84        scale_origin: tuple[float, float] | None
  85        if origin_widget is not None:
  86            self._transition_out = 'out_scale'
  87            scale_origin = origin_widget.get_screen_space_center()
  88            transition = 'in_scale'
  89        else:
  90            self._transition_out = 'out_right'
  91            scale_origin = None
  92
  93        # Try to recreate the same number of buttons we had last time so our
  94        # re-selection code works.
  95        self._tournament_button_count = app.config.get('Tournament Rows', 0)
  96        assert isinstance(self._tournament_button_count, int)
  97
  98        self._easy_button: ba.Widget | None = None
  99        self._hard_button: ba.Widget | None = None
 100        self._hard_button_lock_image: ba.Widget | None = None
 101        self._campaign_percent_text: ba.Widget | None = None
 102
 103        uiscale = ba.app.ui.uiscale
 104        self._width = 1320 if uiscale is ba.UIScale.SMALL else 1120
 105        self._x_inset = x_inset = 100 if uiscale is ba.UIScale.SMALL else 0
 106        self._height = (
 107            657
 108            if uiscale is ba.UIScale.SMALL
 109            else 730
 110            if uiscale is ba.UIScale.MEDIUM
 111            else 800
 112        )
 113        app.ui.set_main_menu_location('Coop Select')
 114        self._r = 'coopSelectWindow'
 115        top_extra = 20 if uiscale is ba.UIScale.SMALL else 0
 116
 117        self._tourney_data_up_to_date = False
 118
 119        self._campaign_difficulty = ba.internal.get_v1_account_misc_val(
 120            'campaignDifficulty', 'easy'
 121        )
 122
 123        super().__init__(
 124            root_widget=ba.containerwidget(
 125                size=(self._width, self._height + top_extra),
 126                toolbar_visibility='menu_full',
 127                scale_origin_stack_offset=scale_origin,
 128                stack_offset=(
 129                    (0, -15)
 130                    if uiscale is ba.UIScale.SMALL
 131                    else (0, 0)
 132                    if uiscale is ba.UIScale.MEDIUM
 133                    else (0, 0)
 134                ),
 135                transition=transition,
 136                scale=(
 137                    1.2
 138                    if uiscale is ba.UIScale.SMALL
 139                    else 0.8
 140                    if uiscale is ba.UIScale.MEDIUM
 141                    else 0.75
 142                ),
 143            )
 144        )
 145
 146        if app.ui.use_toolbars and uiscale is ba.UIScale.SMALL:
 147            self._back_button = None
 148        else:
 149            self._back_button = ba.buttonwidget(
 150                parent=self._root_widget,
 151                position=(
 152                    75 + x_inset,
 153                    self._height
 154                    - 87
 155                    - (4 if uiscale is ba.UIScale.SMALL else 0),
 156                ),
 157                size=(120, 60),
 158                scale=1.2,
 159                autoselect=True,
 160                label=ba.Lstr(resource='backText'),
 161                button_type='back',
 162            )
 163
 164        self._league_rank_button: LeagueRankButton | None
 165        self._store_button: StoreButton | None
 166        self._store_button_widget: ba.Widget | None
 167        self._league_rank_button_widget: ba.Widget | None
 168
 169        if not app.ui.use_toolbars:
 170            prb = self._league_rank_button = LeagueRankButton(
 171                parent=self._root_widget,
 172                position=(
 173                    self._width - (282 + x_inset),
 174                    self._height
 175                    - 85
 176                    - (4 if uiscale is ba.UIScale.SMALL else 0),
 177                ),
 178                size=(100, 60),
 179                color=(0.4, 0.4, 0.9),
 180                textcolor=(0.9, 0.9, 2.0),
 181                scale=1.05,
 182                on_activate_call=ba.WeakCall(self._switch_to_league_rankings),
 183            )
 184            self._league_rank_button_widget = prb.get_button()
 185
 186            sbtn = self._store_button = StoreButton(
 187                parent=self._root_widget,
 188                position=(
 189                    self._width - (170 + x_inset),
 190                    self._height
 191                    - 85
 192                    - (4 if uiscale is ba.UIScale.SMALL else 0),
 193                ),
 194                size=(100, 60),
 195                color=(0.6, 0.4, 0.7),
 196                show_tickets=True,
 197                button_type='square',
 198                sale_scale=0.85,
 199                textcolor=(0.9, 0.7, 1.0),
 200                scale=1.05,
 201                on_activate_call=ba.WeakCall(self._switch_to_score, None),
 202            )
 203            self._store_button_widget = sbtn.get_button()
 204            ba.widget(
 205                edit=self._back_button,
 206                right_widget=self._league_rank_button_widget,
 207            )
 208            ba.widget(
 209                edit=self._league_rank_button_widget,
 210                left_widget=self._back_button,
 211            )
 212        else:
 213            self._league_rank_button = None
 214            self._store_button = None
 215            self._store_button_widget = None
 216            self._league_rank_button_widget = None
 217
 218        # Move our corner buttons dynamically to keep them out of the way of
 219        # the party icon :-(
 220        self._update_corner_button_positions()
 221        self._update_corner_button_positions_timer = ba.Timer(
 222            1.0,
 223            ba.WeakCall(self._update_corner_button_positions),
 224            repeat=True,
 225            timetype=ba.TimeType.REAL,
 226        )
 227
 228        self._last_tournament_query_time: float | None = None
 229        self._last_tournament_query_response_time: float | None = None
 230        self._doing_tournament_query = False
 231
 232        self._selected_campaign_level = cfg.get(
 233            'Selected Coop Campaign Level', None
 234        )
 235        self._selected_custom_level = cfg.get(
 236            'Selected Coop Custom Level', None
 237        )
 238
 239        # Don't want initial construction affecting our last-selected.
 240        self._do_selection_callbacks = False
 241        v = self._height - 95
 242        txt = ba.textwidget(
 243            parent=self._root_widget,
 244            position=(
 245                self._width * 0.5,
 246                v + 40 - (0 if uiscale is ba.UIScale.SMALL else 0),
 247            ),
 248            size=(0, 0),
 249            text=ba.Lstr(
 250                resource='playModes.singlePlayerCoopText',
 251                fallback_resource='playModes.coopText',
 252            ),
 253            h_align='center',
 254            color=app.ui.title_color,
 255            scale=1.5,
 256            maxwidth=500,
 257            v_align='center',
 258        )
 259
 260        if app.ui.use_toolbars and uiscale is ba.UIScale.SMALL:
 261            ba.textwidget(edit=txt, text='')
 262
 263        if self._back_button is not None:
 264            ba.buttonwidget(
 265                edit=self._back_button,
 266                button_type='backSmall',
 267                size=(60, 50),
 268                position=(
 269                    75 + x_inset,
 270                    self._height
 271                    - 87
 272                    - (4 if uiscale is ba.UIScale.SMALL else 0)
 273                    + 6,
 274                ),
 275                label=ba.charstr(ba.SpecialChar.BACK),
 276            )
 277
 278        self._selected_row = cfg.get('Selected Coop Row', None)
 279
 280        self.star_tex = ba.gettexture('star')
 281        self.lsbt = ba.getmodel('level_select_button_transparent')
 282        self.lsbo = ba.getmodel('level_select_button_opaque')
 283        self.a_outline_tex = ba.gettexture('achievementOutline')
 284        self.a_outline_model = ba.getmodel('achievementOutline')
 285
 286        self._scroll_width = self._width - (130 + 2 * x_inset)
 287        self._scroll_height = self._height - (
 288            190 if uiscale is ba.UIScale.SMALL and app.ui.use_toolbars else 160
 289        )
 290
 291        self._subcontainerwidth = 800.0
 292        self._subcontainerheight = 1400.0
 293
 294        self._scrollwidget = ba.scrollwidget(
 295            parent=self._root_widget,
 296            highlight=False,
 297            position=(65 + x_inset, 120)
 298            if uiscale is ba.UIScale.SMALL and app.ui.use_toolbars
 299            else (65 + x_inset, 70),
 300            size=(self._scroll_width, self._scroll_height),
 301            simple_culling_v=10.0,
 302            claims_left_right=True,
 303            claims_tab=True,
 304            selection_loops_to_parent=True,
 305        )
 306        self._subcontainer: ba.Widget | None = None
 307
 308        # Take note of our account state; we'll refresh later if this changes.
 309        self._account_state_num = ba.internal.get_v1_account_state_num()
 310
 311        # Same for fg/bg state.
 312        self._fg_state = app.fg_state
 313
 314        self._refresh()
 315        self._restore_state()
 316
 317        # Even though we might display cached tournament data immediately, we
 318        # don't consider it valid until we've pinged.
 319        # the server for an update
 320        self._tourney_data_up_to_date = False
 321
 322        # If we've got a cached tournament list for our account and info for
 323        # each one of those tournaments, go ahead and display it as a
 324        # starting point.
 325        if (
 326            app.accounts_v1.account_tournament_list is not None
 327            and app.accounts_v1.account_tournament_list[0]
 328            == ba.internal.get_v1_account_state_num()
 329            and all(
 330                t_id in app.accounts_v1.tournament_info
 331                for t_id in app.accounts_v1.account_tournament_list[1]
 332            )
 333        ):
 334            tourney_data = [
 335                app.accounts_v1.tournament_info[t_id]
 336                for t_id in app.accounts_v1.account_tournament_list[1]
 337            ]
 338            self._update_for_data(tourney_data)
 339
 340        # This will pull new data periodically, update timers, etc.
 341        self._update_timer = ba.Timer(
 342            1.0,
 343            ba.WeakCall(self._update),
 344            timetype=ba.TimeType.REAL,
 345            repeat=True,
 346        )
 347        self._update()
 348
 349    # noinspection PyUnresolvedReferences
 350    @staticmethod
 351    def _preload_modules() -> None:
 352        """Preload modules we use (called in bg thread)."""
 353        import bastd.ui.purchase as _unused1
 354        import bastd.ui.coop.gamebutton as _unused2
 355        import bastd.ui.confirm as _unused3
 356        import bastd.ui.account as _unused4
 357        import bastd.ui.league.rankwindow as _unused5
 358        import bastd.ui.store.browser as _unused6
 359        import bastd.ui.account.viewer as _unused7
 360        import bastd.ui.tournamentscores as _unused8
 361        import bastd.ui.tournamententry as _unused9
 362        import bastd.ui.play as _unused10
 363        import bastd.ui.coop.tournamentbutton as _unused11
 364
 365    def _update(self) -> None:
 366        # Do nothing if we've somehow outlived our actual UI.
 367        if not self._root_widget:
 368            return
 369
 370        cur_time = ba.time(ba.TimeType.REAL)
 371
 372        # If its been a while since we got a tournament update, consider the
 373        # data invalid (prevents us from joining tournaments if our internet
 374        # connection goes down for a while).
 375        if (
 376            self._last_tournament_query_response_time is None
 377            or ba.time(ba.TimeType.REAL)
 378            - self._last_tournament_query_response_time
 379            > 60.0 * 2
 380        ):
 381            self._tourney_data_up_to_date = False
 382
 383        # If our account state has changed, do a full request.
 384        account_state_num = ba.internal.get_v1_account_state_num()
 385        if account_state_num != self._account_state_num:
 386            self._account_state_num = account_state_num
 387            self._save_state()
 388            self._refresh()
 389
 390            # Also encourage a new tournament query since this will clear out
 391            # our current results.
 392            if not self._doing_tournament_query:
 393                self._last_tournament_query_time = None
 394
 395        # If we've been backgrounded/foregrounded, invalidate our
 396        # tournament entries (they will be refreshed below asap).
 397        if self._fg_state != ba.app.fg_state:
 398            self._tourney_data_up_to_date = False
 399
 400        # Send off a new tournament query if its been long enough or whatnot.
 401        if not self._doing_tournament_query and (
 402            self._last_tournament_query_time is None
 403            or cur_time - self._last_tournament_query_time > 30.0
 404            or self._fg_state != ba.app.fg_state
 405        ):
 406            self._fg_state = ba.app.fg_state
 407            self._last_tournament_query_time = cur_time
 408            self._doing_tournament_query = True
 409            ba.internal.tournament_query(
 410                args={'source': 'coop window refresh', 'numScores': 1},
 411                callback=ba.WeakCall(self._on_tournament_query_response),
 412            )
 413
 414        # Decrement time on our tournament buttons.
 415        ads_enabled = ba.internal.have_incentivized_ad()
 416        for tbtn in self._tournament_buttons:
 417            tbtn.time_remaining = max(0, tbtn.time_remaining - 1)
 418            if tbtn.time_remaining_value_text is not None:
 419                ba.textwidget(
 420                    edit=tbtn.time_remaining_value_text,
 421                    text=ba.timestring(
 422                        tbtn.time_remaining,
 423                        centi=False,
 424                        suppress_format_warning=True,
 425                    )
 426                    if (
 427                        tbtn.has_time_remaining
 428                        and self._tourney_data_up_to_date
 429                    )
 430                    else '-',
 431                )
 432
 433            # Also adjust the ad icon visibility.
 434            if tbtn.allow_ads and ba.internal.has_video_ads():
 435                ba.imagewidget(
 436                    edit=tbtn.entry_fee_ad_image,
 437                    opacity=1.0 if ads_enabled else 0.25,
 438                )
 439                ba.textwidget(
 440                    edit=tbtn.entry_fee_text_remaining,
 441                    color=(0.6, 0.6, 0.6, 1 if ads_enabled else 0.2),
 442                )
 443
 444        self._update_hard_mode_lock_image()
 445
 446    def _update_hard_mode_lock_image(self) -> None:
 447        try:
 448            ba.imagewidget(
 449                edit=self._hard_button_lock_image,
 450                opacity=0.0 if ba.app.accounts_v1.have_pro_options() else 1.0,
 451            )
 452        except Exception:
 453            ba.print_exception('Error updating campaign lock.')
 454
 455    def _update_for_data(self, data: list[dict[str, Any]] | None) -> None:
 456
 457        # If the number of tournaments or challenges in the data differs from
 458        # our current arrangement, refresh with the new number.
 459        if (data is None and self._tournament_button_count != 0) or (
 460            data is not None and (len(data) != self._tournament_button_count)
 461        ):
 462            self._tournament_button_count = len(data) if data is not None else 0
 463            ba.app.config['Tournament Rows'] = self._tournament_button_count
 464            self._refresh()
 465
 466        # Update all of our tourney buttons based on whats in data.
 467        for i, tbtn in enumerate(self._tournament_buttons):
 468            assert data is not None
 469            tbtn.update_for_data(data[i])
 470
 471    def _on_tournament_query_response(
 472        self, data: dict[str, Any] | None
 473    ) -> None:
 474        accounts = ba.app.accounts_v1
 475        if data is not None:
 476            tournament_data = data['t']  # This used to be the whole payload.
 477            self._last_tournament_query_response_time = ba.time(
 478                ba.TimeType.REAL
 479            )
 480        else:
 481            tournament_data = None
 482
 483        # Keep our cached tourney info up to date.
 484        if data is not None:
 485            self._tourney_data_up_to_date = True
 486            accounts.cache_tournament_info(tournament_data)
 487
 488            # Also cache the current tourney list/order for this account.
 489            accounts.account_tournament_list = (
 490                ba.internal.get_v1_account_state_num(),
 491                [e['tournamentID'] for e in tournament_data],
 492            )
 493
 494        self._doing_tournament_query = False
 495        self._update_for_data(tournament_data)
 496
 497    def _set_campaign_difficulty(self, difficulty: str) -> None:
 498        # pylint: disable=cyclic-import
 499        from bastd.ui.purchase import PurchaseWindow
 500
 501        if difficulty != self._campaign_difficulty:
 502            if (
 503                difficulty == 'hard'
 504                and not ba.app.accounts_v1.have_pro_options()
 505            ):
 506                PurchaseWindow(items=['pro'])
 507                return
 508            ba.playsound(ba.getsound('gunCocking'))
 509            if difficulty not in ('easy', 'hard'):
 510                print('ERROR: invalid campaign difficulty:', difficulty)
 511                difficulty = 'easy'
 512            self._campaign_difficulty = difficulty
 513            ba.internal.add_transaction(
 514                {
 515                    'type': 'SET_MISC_VAL',
 516                    'name': 'campaignDifficulty',
 517                    'value': difficulty,
 518                }
 519            )
 520            self._refresh_campaign_row()
 521        else:
 522            ba.playsound(ba.getsound('click01'))
 523
 524    def _refresh_campaign_row(self) -> None:
 525        # pylint: disable=too-many-locals
 526        # pylint: disable=cyclic-import
 527        from ba.internal import getcampaign
 528        from bastd.ui.coop.gamebutton import GameButton
 529
 530        parent_widget = self._campaign_sub_container
 531
 532        # Clear out anything in the parent widget already.
 533        for child in parent_widget.get_children():
 534            child.delete()
 535
 536        next_widget_down = self._tournament_info_button
 537
 538        h = 0
 539        v2 = -2
 540        sel_color = (0.75, 0.85, 0.5)
 541        sel_color_hard = (0.4, 0.7, 0.2)
 542        un_sel_color = (0.5, 0.5, 0.5)
 543        sel_textcolor = (2, 2, 0.8)
 544        un_sel_textcolor = (0.6, 0.6, 0.6)
 545        self._easy_button = ba.buttonwidget(
 546            parent=parent_widget,
 547            position=(h + 30, v2 + 105),
 548            size=(120, 70),
 549            label=ba.Lstr(resource='difficultyEasyText'),
 550            button_type='square',
 551            autoselect=True,
 552            enable_sound=False,
 553            on_activate_call=ba.Call(self._set_campaign_difficulty, 'easy'),
 554            on_select_call=ba.Call(self.sel_change, 'campaign', 'easyButton'),
 555            color=sel_color
 556            if self._campaign_difficulty == 'easy'
 557            else un_sel_color,
 558            textcolor=sel_textcolor
 559            if self._campaign_difficulty == 'easy'
 560            else un_sel_textcolor,
 561        )
 562        ba.widget(edit=self._easy_button, show_buffer_left=100)
 563        if self._selected_campaign_level == 'easyButton':
 564            ba.containerwidget(
 565                edit=parent_widget,
 566                selected_child=self._easy_button,
 567                visible_child=self._easy_button,
 568            )
 569        lock_tex = ba.gettexture('lock')
 570
 571        self._hard_button = ba.buttonwidget(
 572            parent=parent_widget,
 573            position=(h + 30, v2 + 32),
 574            size=(120, 70),
 575            label=ba.Lstr(resource='difficultyHardText'),
 576            button_type='square',
 577            autoselect=True,
 578            enable_sound=False,
 579            on_activate_call=ba.Call(self._set_campaign_difficulty, 'hard'),
 580            on_select_call=ba.Call(self.sel_change, 'campaign', 'hardButton'),
 581            color=sel_color_hard
 582            if self._campaign_difficulty == 'hard'
 583            else un_sel_color,
 584            textcolor=sel_textcolor
 585            if self._campaign_difficulty == 'hard'
 586            else un_sel_textcolor,
 587        )
 588        self._hard_button_lock_image = ba.imagewidget(
 589            parent=parent_widget,
 590            size=(30, 30),
 591            draw_controller=self._hard_button,
 592            position=(h + 30 - 10, v2 + 32 + 70 - 35),
 593            texture=lock_tex,
 594        )
 595        self._update_hard_mode_lock_image()
 596        ba.widget(edit=self._hard_button, show_buffer_left=100)
 597        if self._selected_campaign_level == 'hardButton':
 598            ba.containerwidget(
 599                edit=parent_widget,
 600                selected_child=self._hard_button,
 601                visible_child=self._hard_button,
 602            )
 603
 604        ba.widget(edit=self._hard_button, down_widget=next_widget_down)
 605        h_spacing = 200
 606        campaign_buttons = []
 607        if self._campaign_difficulty == 'easy':
 608            campaignname = 'Easy'
 609        else:
 610            campaignname = 'Default'
 611        items = [
 612            campaignname + ':Onslaught Training',
 613            campaignname + ':Rookie Onslaught',
 614            campaignname + ':Rookie Football',
 615            campaignname + ':Pro Onslaught',
 616            campaignname + ':Pro Football',
 617            campaignname + ':Pro Runaround',
 618            campaignname + ':Uber Onslaught',
 619            campaignname + ':Uber Football',
 620            campaignname + ':Uber Runaround',
 621        ]
 622        items += [campaignname + ':The Last Stand']
 623        if self._selected_campaign_level is None:
 624            self._selected_campaign_level = items[0]
 625        h = 150
 626        for i in items:
 627            is_last_sel = i == self._selected_campaign_level
 628            campaign_buttons.append(
 629                GameButton(
 630                    self, parent_widget, i, h, v2, is_last_sel, 'campaign'
 631                ).get_button()
 632            )
 633            h += h_spacing
 634
 635        ba.widget(edit=campaign_buttons[0], left_widget=self._easy_button)
 636
 637        if self._back_button is not None:
 638            ba.widget(edit=self._easy_button, up_widget=self._back_button)
 639            for btn in campaign_buttons:
 640                ba.widget(
 641                    edit=btn,
 642                    up_widget=self._back_button,
 643                    down_widget=next_widget_down,
 644                )
 645
 646        # Update our existing percent-complete text.
 647        campaign = getcampaign(campaignname)
 648        levels = campaign.levels
 649        levels_complete = sum((1 if l.complete else 0) for l in levels)
 650
 651        # Last level cant be completed; hence the -1.
 652        progress = min(1.0, float(levels_complete) / (len(levels) - 1))
 653        p_str = str(int(progress * 100.0)) + '%'
 654
 655        self._campaign_percent_text = ba.textwidget(
 656            edit=self._campaign_percent_text,
 657            text=ba.Lstr(
 658                value='${C} (${P})',
 659                subs=[
 660                    ('${C}', ba.Lstr(resource=self._r + '.campaignText')),
 661                    ('${P}', p_str),
 662                ],
 663            ),
 664        )
 665
 666    def _on_tournament_info_press(self) -> None:
 667        # pylint: disable=cyclic-import
 668        from bastd.ui.confirm import ConfirmWindow
 669
 670        txt = ba.Lstr(resource=self._r + '.tournamentInfoText')
 671        ConfirmWindow(
 672            txt,
 673            cancel_button=False,
 674            width=550,
 675            height=260,
 676            origin_widget=self._tournament_info_button,
 677        )
 678
 679    def _refresh(self) -> None:
 680        # pylint: disable=too-many-statements
 681        # pylint: disable=too-many-branches
 682        # pylint: disable=too-many-locals
 683        # pylint: disable=cyclic-import
 684        from bastd.ui.coop.gamebutton import GameButton
 685        from bastd.ui.coop.tournamentbutton import TournamentButton
 686
 687        # (Re)create the sub-container if need be.
 688        if self._subcontainer is not None:
 689            self._subcontainer.delete()
 690
 691        tourney_row_height = 200
 692        self._subcontainerheight = (
 693            620 + self._tournament_button_count * tourney_row_height
 694        )
 695
 696        self._subcontainer = ba.containerwidget(
 697            parent=self._scrollwidget,
 698            size=(self._subcontainerwidth, self._subcontainerheight),
 699            background=False,
 700            claims_left_right=True,
 701            claims_tab=True,
 702            selection_loops_to_parent=True,
 703        )
 704
 705        ba.containerwidget(
 706            edit=self._root_widget, selected_child=self._scrollwidget
 707        )
 708        if self._back_button is not None:
 709            ba.containerwidget(
 710                edit=self._root_widget, cancel_button=self._back_button
 711            )
 712
 713        w_parent = self._subcontainer
 714        h_base = 6
 715
 716        v = self._subcontainerheight - 73
 717
 718        self._campaign_percent_text = ba.textwidget(
 719            parent=w_parent,
 720            position=(h_base + 27, v + 30),
 721            size=(0, 0),
 722            text='',
 723            h_align='left',
 724            v_align='center',
 725            color=ba.app.ui.title_color,
 726            scale=1.1,
 727        )
 728
 729        row_v_show_buffer = 100
 730        v -= 198
 731
 732        h_scroll = ba.hscrollwidget(
 733            parent=w_parent,
 734            size=(self._scroll_width - 10, 205),
 735            position=(-5, v),
 736            simple_culling_h=70,
 737            highlight=False,
 738            border_opacity=0.0,
 739            color=(0.45, 0.4, 0.5),
 740            on_select_call=lambda: self._on_row_selected('campaign'),
 741        )
 742        self._campaign_h_scroll = h_scroll
 743        ba.widget(
 744            edit=h_scroll,
 745            show_buffer_top=row_v_show_buffer,
 746            show_buffer_bottom=row_v_show_buffer,
 747            autoselect=True,
 748        )
 749        if self._selected_row == 'campaign':
 750            ba.containerwidget(
 751                edit=w_parent, selected_child=h_scroll, visible_child=h_scroll
 752            )
 753        ba.containerwidget(edit=h_scroll, claims_left_right=True)
 754        self._campaign_sub_container = ba.containerwidget(
 755            parent=h_scroll, size=(180 + 200 * 10, 200), background=False
 756        )
 757
 758        # Tournaments
 759
 760        self._tournament_buttons: list[TournamentButton] = []
 761
 762        v -= 53
 763        # FIXME shouldn't use hard-coded strings here.
 764        txt = ba.Lstr(
 765            resource='tournamentsText', fallback_resource='tournamentText'
 766        ).evaluate()
 767        t_width = ba.internal.get_string_width(txt, suppress_warning=True)
 768        ba.textwidget(
 769            parent=w_parent,
 770            position=(h_base + 27, v + 30),
 771            size=(0, 0),
 772            text=txt,
 773            h_align='left',
 774            v_align='center',
 775            color=ba.app.ui.title_color,
 776            scale=1.1,
 777        )
 778        self._tournament_info_button = ba.buttonwidget(
 779            parent=w_parent,
 780            label='?',
 781            size=(20, 20),
 782            text_scale=0.6,
 783            position=(h_base + 27 + t_width * 1.1 + 15, v + 18),
 784            button_type='square',
 785            color=(0.6, 0.5, 0.65),
 786            textcolor=(0.7, 0.6, 0.75),
 787            autoselect=True,
 788            up_widget=self._campaign_h_scroll,
 789            on_activate_call=self._on_tournament_info_press,
 790        )
 791        ba.widget(
 792            edit=self._tournament_info_button,
 793            left_widget=self._tournament_info_button,
 794            right_widget=self._tournament_info_button,
 795        )
 796
 797        # Say 'unavailable' if there are zero tournaments, and if we're not
 798        # signed in add that as well (that's probably why we see
 799        # no tournaments).
 800        if self._tournament_button_count == 0:
 801            unavailable_text = ba.Lstr(resource='unavailableText')
 802            if ba.internal.get_v1_account_state() != 'signed_in':
 803                unavailable_text = ba.Lstr(
 804                    value='${A} (${B})',
 805                    subs=[
 806                        ('${A}', unavailable_text),
 807                        ('${B}', ba.Lstr(resource='notSignedInText')),
 808                    ],
 809                )
 810            ba.textwidget(
 811                parent=w_parent,
 812                position=(h_base + 47, v),
 813                size=(0, 0),
 814                text=unavailable_text,
 815                h_align='left',
 816                v_align='center',
 817                color=ba.app.ui.title_color,
 818                scale=0.9,
 819            )
 820            v -= 40
 821        v -= 198
 822
 823        tournament_h_scroll = None
 824        if self._tournament_button_count > 0:
 825            for i in range(self._tournament_button_count):
 826                tournament_h_scroll = h_scroll = ba.hscrollwidget(
 827                    parent=w_parent,
 828                    size=(self._scroll_width - 10, 205),
 829                    position=(-5, v),
 830                    highlight=False,
 831                    border_opacity=0.0,
 832                    color=(0.45, 0.4, 0.5),
 833                    on_select_call=ba.Call(
 834                        self._on_row_selected, 'tournament' + str(i + 1)
 835                    ),
 836                )
 837                ba.widget(
 838                    edit=h_scroll,
 839                    show_buffer_top=row_v_show_buffer,
 840                    show_buffer_bottom=row_v_show_buffer,
 841                    autoselect=True,
 842                )
 843                if self._selected_row == 'tournament' + str(i + 1):
 844                    ba.containerwidget(
 845                        edit=w_parent,
 846                        selected_child=h_scroll,
 847                        visible_child=h_scroll,
 848                    )
 849                ba.containerwidget(edit=h_scroll, claims_left_right=True)
 850                sc2 = ba.containerwidget(
 851                    parent=h_scroll,
 852                    size=(self._scroll_width - 24, 200),
 853                    background=False,
 854                )
 855                h = 0
 856                v2 = -2
 857                is_last_sel = True
 858                self._tournament_buttons.append(
 859                    TournamentButton(
 860                        sc2,
 861                        h,
 862                        v2,
 863                        is_last_sel,
 864                        on_pressed=ba.WeakCall(self.run_tournament),
 865                    )
 866                )
 867                v -= 200
 868
 869        # Custom Games. (called 'Practice' in UI these days).
 870        v -= 50
 871        ba.textwidget(
 872            parent=w_parent,
 873            position=(h_base + 27, v + 30 + 198),
 874            size=(0, 0),
 875            text=ba.Lstr(
 876                resource='practiceText',
 877                fallback_resource='coopSelectWindow.customText',
 878            ),
 879            h_align='left',
 880            v_align='center',
 881            color=ba.app.ui.title_color,
 882            scale=1.1,
 883        )
 884
 885        items = [
 886            'Challenges:Infinite Onslaught',
 887            'Challenges:Infinite Runaround',
 888            'Challenges:Ninja Fight',
 889            'Challenges:Pro Ninja Fight',
 890            'Challenges:Meteor Shower',
 891            'Challenges:Target Practice B',
 892            'Challenges:Target Practice',
 893        ]
 894
 895        # Show easter-egg-hunt either if its easter or we own it.
 896        if ba.internal.get_v1_account_misc_read_val(
 897            'easter', False
 898        ) or ba.internal.get_purchased('games.easter_egg_hunt'):
 899            items = [
 900                'Challenges:Easter Egg Hunt',
 901                'Challenges:Pro Easter Egg Hunt',
 902            ] + items
 903
 904        # If we've defined custom games, put them at the beginning.
 905        if ba.app.custom_coop_practice_games:
 906            items = ba.app.custom_coop_practice_games + items
 907
 908        self._custom_h_scroll = custom_h_scroll = h_scroll = ba.hscrollwidget(
 909            parent=w_parent,
 910            size=(self._scroll_width - 10, 205),
 911            position=(-5, v),
 912            highlight=False,
 913            border_opacity=0.0,
 914            color=(0.45, 0.4, 0.5),
 915            on_select_call=ba.Call(self._on_row_selected, 'custom'),
 916        )
 917        ba.widget(
 918            edit=h_scroll,
 919            show_buffer_top=row_v_show_buffer,
 920            show_buffer_bottom=1.5 * row_v_show_buffer,
 921            autoselect=True,
 922        )
 923        if self._selected_row == 'custom':
 924            ba.containerwidget(
 925                edit=w_parent, selected_child=h_scroll, visible_child=h_scroll
 926            )
 927        ba.containerwidget(edit=h_scroll, claims_left_right=True)
 928        sc2 = ba.containerwidget(
 929            parent=h_scroll,
 930            size=(max(self._scroll_width - 24, 30 + 200 * len(items)), 200),
 931            background=False,
 932        )
 933        h_spacing = 200
 934        self._custom_buttons: list[GameButton] = []
 935        h = 0
 936        v2 = -2
 937        for item in items:
 938            is_last_sel = item == self._selected_custom_level
 939            self._custom_buttons.append(
 940                GameButton(self, sc2, item, h, v2, is_last_sel, 'custom')
 941            )
 942            h += h_spacing
 943
 944        # We can't fill in our campaign row until tourney buttons are in place.
 945        # (for wiring up)
 946        self._refresh_campaign_row()
 947
 948        for i, tbutton in enumerate(self._tournament_buttons):
 949            ba.widget(
 950                edit=tbutton.button,
 951                up_widget=self._tournament_info_button
 952                if i == 0
 953                else self._tournament_buttons[i - 1].button,
 954                down_widget=self._tournament_buttons[(i + 1)].button
 955                if i + 1 < len(self._tournament_buttons)
 956                else custom_h_scroll,
 957            )
 958            ba.widget(
 959                edit=tbutton.more_scores_button,
 960                down_widget=self._tournament_buttons[
 961                    (i + 1)
 962                ].current_leader_name_text
 963                if i + 1 < len(self._tournament_buttons)
 964                else custom_h_scroll,
 965            )
 966            ba.widget(
 967                edit=tbutton.current_leader_name_text,
 968                up_widget=self._tournament_info_button
 969                if i == 0
 970                else self._tournament_buttons[i - 1].more_scores_button,
 971            )
 972
 973        for btn in self._custom_buttons:
 974            try:
 975                ba.widget(
 976                    edit=btn.get_button(),
 977                    up_widget=tournament_h_scroll
 978                    if self._tournament_buttons
 979                    else self._tournament_info_button,
 980                )
 981            except Exception:
 982                ba.print_exception('Error wiring up custom buttons.')
 983
 984        if self._back_button is not None:
 985            ba.buttonwidget(edit=self._back_button, on_activate_call=self._back)
 986        else:
 987            ba.containerwidget(
 988                edit=self._root_widget, on_cancel_call=self._back
 989            )
 990
 991        # There's probably several 'onSelected' callbacks pushed onto the
 992        # event queue.. we need to push ours too so we're enabled *after* them.
 993        ba.pushcall(self._enable_selectable_callback)
 994
 995    def _on_row_selected(self, row: str) -> None:
 996        if self._do_selection_callbacks:
 997            if self._selected_row != row:
 998                self._selected_row = row
 999
1000    def _enable_selectable_callback(self) -> None:
1001        self._do_selection_callbacks = True
1002
1003    def _switch_to_league_rankings(self) -> None:
1004        # pylint: disable=cyclic-import
1005        from bastd.ui.account import show_sign_in_prompt
1006        from bastd.ui.league.rankwindow import LeagueRankWindow
1007
1008        if ba.internal.get_v1_account_state() != 'signed_in':
1009            show_sign_in_prompt()
1010            return
1011        self._save_state()
1012        ba.containerwidget(edit=self._root_widget, transition='out_left')
1013        assert self._league_rank_button is not None
1014        ba.app.ui.set_main_menu_window(
1015            LeagueRankWindow(
1016                origin_widget=self._league_rank_button.get_button()
1017            ).get_root_widget()
1018        )
1019
1020    def _switch_to_score(
1021        self,
1022        show_tab: StoreBrowserWindow.TabID
1023        | None = StoreBrowserWindow.TabID.EXTRAS,
1024    ) -> None:
1025        # pylint: disable=cyclic-import
1026        from bastd.ui.account import show_sign_in_prompt
1027
1028        if ba.internal.get_v1_account_state() != 'signed_in':
1029            show_sign_in_prompt()
1030            return
1031        self._save_state()
1032        ba.containerwidget(edit=self._root_widget, transition='out_left')
1033        assert self._store_button is not None
1034        ba.app.ui.set_main_menu_window(
1035            StoreBrowserWindow(
1036                origin_widget=self._store_button.get_button(),
1037                show_tab=show_tab,
1038                back_location='CoopBrowserWindow',
1039            ).get_root_widget()
1040        )
1041
1042    def is_tourney_data_up_to_date(self) -> bool:
1043        """Return whether our tourney data is up to date."""
1044        return self._tourney_data_up_to_date
1045
1046    def run_game(self, game: str) -> None:
1047        """Run the provided game."""
1048        # pylint: disable=too-many-branches
1049        # pylint: disable=cyclic-import
1050        from bastd.ui.confirm import ConfirmWindow
1051        from bastd.ui.purchase import PurchaseWindow
1052        from bastd.ui.account import show_sign_in_prompt
1053
1054        args: dict[str, Any] = {}
1055
1056        if game == 'Easy:The Last Stand':
1057            ConfirmWindow(
1058                ba.Lstr(
1059                    resource='difficultyHardUnlockOnlyText',
1060                    fallback_resource='difficultyHardOnlyText',
1061                ),
1062                cancel_button=False,
1063                width=460,
1064                height=130,
1065            )
1066            return
1067
1068        # Infinite onslaught/runaround require pro; bring up a store link
1069        # if need be.
1070        if (
1071            game
1072            in (
1073                'Challenges:Infinite Runaround',
1074                'Challenges:Infinite Onslaught',
1075            )
1076            and not ba.app.accounts_v1.have_pro()
1077        ):
1078            if ba.internal.get_v1_account_state() != 'signed_in':
1079                show_sign_in_prompt()
1080            else:
1081                PurchaseWindow(items=['pro'])
1082            return
1083
1084        required_purchase: str | None
1085        if game in ['Challenges:Meteor Shower']:
1086            required_purchase = 'games.meteor_shower'
1087        elif game in [
1088            'Challenges:Target Practice',
1089            'Challenges:Target Practice B',
1090        ]:
1091            required_purchase = 'games.target_practice'
1092        elif game in ['Challenges:Ninja Fight']:
1093            required_purchase = 'games.ninja_fight'
1094        elif game in ['Challenges:Pro Ninja Fight']:
1095            required_purchase = 'games.ninja_fight'
1096        elif game in [
1097            'Challenges:Easter Egg Hunt',
1098            'Challenges:Pro Easter Egg Hunt',
1099        ]:
1100            required_purchase = 'games.easter_egg_hunt'
1101        else:
1102            required_purchase = None
1103
1104        if required_purchase is not None and not ba.internal.get_purchased(
1105            required_purchase
1106        ):
1107            if ba.internal.get_v1_account_state() != 'signed_in':
1108                show_sign_in_prompt()
1109            else:
1110                PurchaseWindow(items=[required_purchase])
1111            return
1112
1113        self._save_state()
1114
1115        if ba.app.launch_coop_game(game, args=args):
1116            ba.containerwidget(edit=self._root_widget, transition='out_left')
1117
1118    def run_tournament(self, tournament_button: TournamentButton) -> None:
1119        """Run the provided tournament game."""
1120        from bastd.ui.account import show_sign_in_prompt
1121        from bastd.ui.tournamententry import TournamentEntryWindow
1122
1123        if ba.internal.get_v1_account_state() != 'signed_in':
1124            show_sign_in_prompt()
1125            return
1126
1127        if ba.internal.workspaces_in_use():
1128            ba.screenmessage(
1129                ba.Lstr(resource='tournamentsDisabledWorkspaceText'),
1130                color=(1, 0, 0),
1131            )
1132            ba.playsound(ba.getsound('error'))
1133            return
1134
1135        if not self._tourney_data_up_to_date:
1136            ba.screenmessage(
1137                ba.Lstr(resource='tournamentCheckingStateText'), color=(1, 1, 0)
1138            )
1139            ba.playsound(ba.getsound('error'))
1140            return
1141
1142        if tournament_button.tournament_id is None:
1143            ba.screenmessage(
1144                ba.Lstr(resource='internal.unavailableNoConnectionText'),
1145                color=(1, 0, 0),
1146            )
1147            ba.playsound(ba.getsound('error'))
1148            return
1149
1150        if tournament_button.required_league is not None:
1151            ba.screenmessage(
1152                ba.Lstr(
1153                    resource='league.tournamentLeagueText',
1154                    subs=[
1155                        (
1156                            '${NAME}',
1157                            ba.Lstr(
1158                                translate=(
1159                                    'leagueNames',
1160                                    tournament_button.required_league,
1161                                )
1162                            ),
1163                        )
1164                    ],
1165                ),
1166                color=(1, 0, 0),
1167            )
1168            ba.playsound(ba.getsound('error'))
1169            return
1170
1171        if tournament_button.time_remaining <= 0:
1172            ba.screenmessage(
1173                ba.Lstr(resource='tournamentEndedText'), color=(1, 0, 0)
1174            )
1175            ba.playsound(ba.getsound('error'))
1176            return
1177
1178        self._save_state()
1179
1180        assert tournament_button.tournament_id is not None
1181        TournamentEntryWindow(
1182            tournament_id=tournament_button.tournament_id,
1183            position=tournament_button.button.get_screen_space_center(),
1184        )
1185
1186    def _back(self) -> None:
1187        # pylint: disable=cyclic-import
1188        from bastd.ui.play import PlayWindow
1189
1190        # If something is selected, store it.
1191        self._save_state()
1192        ba.containerwidget(
1193            edit=self._root_widget, transition=self._transition_out
1194        )
1195        ba.app.ui.set_main_menu_window(
1196            PlayWindow(transition='in_left').get_root_widget()
1197        )
1198
1199    def _save_state(self) -> None:
1200        cfg = ba.app.config
1201        try:
1202            sel = self._root_widget.get_selected_child()
1203            if sel == self._back_button:
1204                sel_name = 'Back'
1205            elif sel == self._store_button_widget:
1206                sel_name = 'Store'
1207            elif sel == self._league_rank_button_widget:
1208                sel_name = 'PowerRanking'
1209            elif sel == self._scrollwidget:
1210                sel_name = 'Scroll'
1211            else:
1212                raise ValueError('unrecognized selection')
1213            ba.app.ui.window_states[type(self)] = {'sel_name': sel_name}
1214        except Exception:
1215            ba.print_exception(f'Error saving state for {self}.')
1216
1217        cfg['Selected Coop Row'] = self._selected_row
1218        cfg['Selected Coop Custom Level'] = self._selected_custom_level
1219        cfg['Selected Coop Campaign Level'] = self._selected_campaign_level
1220        cfg.commit()
1221
1222    def _restore_state(self) -> None:
1223        try:
1224            sel_name = ba.app.ui.window_states.get(type(self), {}).get(
1225                'sel_name'
1226            )
1227            if sel_name == 'Back':
1228                sel = self._back_button
1229            elif sel_name == 'Scroll':
1230                sel = self._scrollwidget
1231            elif sel_name == 'PowerRanking':
1232                sel = self._league_rank_button_widget
1233            elif sel_name == 'Store':
1234                sel = self._store_button_widget
1235            else:
1236                sel = self._scrollwidget
1237            ba.containerwidget(edit=self._root_widget, selected_child=sel)
1238        except Exception:
1239            ba.print_exception(f'Error restoring state for {self}.')
1240
1241    def sel_change(self, row: str, game: str) -> None:
1242        """(internal)"""
1243        if self._do_selection_callbacks:
1244            if row == 'custom':
1245                self._selected_custom_level = game
1246            elif row == 'campaign':
1247                self._selected_campaign_level = game
class CoopBrowserWindow(ba.ui.Window):
  24class CoopBrowserWindow(ba.Window):
  25    """Window for browsing co-op levels/games/etc."""
  26
  27    def _update_corner_button_positions(self) -> None:
  28        uiscale = ba.app.ui.uiscale
  29        offs = (
  30            -55
  31            if uiscale is ba.UIScale.SMALL
  32            and ba.internal.is_party_icon_visible()
  33            else 0
  34        )
  35        if self._league_rank_button is not None:
  36            self._league_rank_button.set_position(
  37                (
  38                    self._width - 282 + offs - self._x_inset,
  39                    self._height
  40                    - 85
  41                    - (4 if uiscale is ba.UIScale.SMALL else 0),
  42                )
  43            )
  44        if self._store_button is not None:
  45            self._store_button.set_position(
  46                (
  47                    self._width - 170 + offs - self._x_inset,
  48                    self._height
  49                    - 85
  50                    - (4 if uiscale is ba.UIScale.SMALL else 0),
  51                )
  52            )
  53
  54    def __init__(
  55        self,
  56        transition: str | None = 'in_right',
  57        origin_widget: ba.Widget | None = None,
  58    ):
  59        # pylint: disable=too-many-statements
  60        # pylint: disable=cyclic-import
  61        import threading
  62
  63        # Preload some modules we use in a background thread so we won't
  64        # have a visual hitch when the user taps them.
  65        threading.Thread(target=self._preload_modules).start()
  66
  67        ba.set_analytics_screen('Coop Window')
  68
  69        app = ba.app
  70        cfg = app.config
  71
  72        # Quick note to players that tourneys won't work in ballistica
  73        # core builds. (need to split the word so it won't get subbed out)
  74        if 'ballistica' + 'core' == ba.internal.appname():
  75            ba.timer(
  76                1.0,
  77                lambda: ba.screenmessage(
  78                    ba.Lstr(resource='noTournamentsInTestBuildText'),
  79                    color=(1, 1, 0),
  80                ),
  81                timetype=ba.TimeType.REAL,
  82            )
  83
  84        # If they provided an origin-widget, scale up from that.
  85        scale_origin: tuple[float, float] | None
  86        if origin_widget is not None:
  87            self._transition_out = 'out_scale'
  88            scale_origin = origin_widget.get_screen_space_center()
  89            transition = 'in_scale'
  90        else:
  91            self._transition_out = 'out_right'
  92            scale_origin = None
  93
  94        # Try to recreate the same number of buttons we had last time so our
  95        # re-selection code works.
  96        self._tournament_button_count = app.config.get('Tournament Rows', 0)
  97        assert isinstance(self._tournament_button_count, int)
  98
  99        self._easy_button: ba.Widget | None = None
 100        self._hard_button: ba.Widget | None = None
 101        self._hard_button_lock_image: ba.Widget | None = None
 102        self._campaign_percent_text: ba.Widget | None = None
 103
 104        uiscale = ba.app.ui.uiscale
 105        self._width = 1320 if uiscale is ba.UIScale.SMALL else 1120
 106        self._x_inset = x_inset = 100 if uiscale is ba.UIScale.SMALL else 0
 107        self._height = (
 108            657
 109            if uiscale is ba.UIScale.SMALL
 110            else 730
 111            if uiscale is ba.UIScale.MEDIUM
 112            else 800
 113        )
 114        app.ui.set_main_menu_location('Coop Select')
 115        self._r = 'coopSelectWindow'
 116        top_extra = 20 if uiscale is ba.UIScale.SMALL else 0
 117
 118        self._tourney_data_up_to_date = False
 119
 120        self._campaign_difficulty = ba.internal.get_v1_account_misc_val(
 121            'campaignDifficulty', 'easy'
 122        )
 123
 124        super().__init__(
 125            root_widget=ba.containerwidget(
 126                size=(self._width, self._height + top_extra),
 127                toolbar_visibility='menu_full',
 128                scale_origin_stack_offset=scale_origin,
 129                stack_offset=(
 130                    (0, -15)
 131                    if uiscale is ba.UIScale.SMALL
 132                    else (0, 0)
 133                    if uiscale is ba.UIScale.MEDIUM
 134                    else (0, 0)
 135                ),
 136                transition=transition,
 137                scale=(
 138                    1.2
 139                    if uiscale is ba.UIScale.SMALL
 140                    else 0.8
 141                    if uiscale is ba.UIScale.MEDIUM
 142                    else 0.75
 143                ),
 144            )
 145        )
 146
 147        if app.ui.use_toolbars and uiscale is ba.UIScale.SMALL:
 148            self._back_button = None
 149        else:
 150            self._back_button = ba.buttonwidget(
 151                parent=self._root_widget,
 152                position=(
 153                    75 + x_inset,
 154                    self._height
 155                    - 87
 156                    - (4 if uiscale is ba.UIScale.SMALL else 0),
 157                ),
 158                size=(120, 60),
 159                scale=1.2,
 160                autoselect=True,
 161                label=ba.Lstr(resource='backText'),
 162                button_type='back',
 163            )
 164
 165        self._league_rank_button: LeagueRankButton | None
 166        self._store_button: StoreButton | None
 167        self._store_button_widget: ba.Widget | None
 168        self._league_rank_button_widget: ba.Widget | None
 169
 170        if not app.ui.use_toolbars:
 171            prb = self._league_rank_button = LeagueRankButton(
 172                parent=self._root_widget,
 173                position=(
 174                    self._width - (282 + x_inset),
 175                    self._height
 176                    - 85
 177                    - (4 if uiscale is ba.UIScale.SMALL else 0),
 178                ),
 179                size=(100, 60),
 180                color=(0.4, 0.4, 0.9),
 181                textcolor=(0.9, 0.9, 2.0),
 182                scale=1.05,
 183                on_activate_call=ba.WeakCall(self._switch_to_league_rankings),
 184            )
 185            self._league_rank_button_widget = prb.get_button()
 186
 187            sbtn = self._store_button = StoreButton(
 188                parent=self._root_widget,
 189                position=(
 190                    self._width - (170 + x_inset),
 191                    self._height
 192                    - 85
 193                    - (4 if uiscale is ba.UIScale.SMALL else 0),
 194                ),
 195                size=(100, 60),
 196                color=(0.6, 0.4, 0.7),
 197                show_tickets=True,
 198                button_type='square',
 199                sale_scale=0.85,
 200                textcolor=(0.9, 0.7, 1.0),
 201                scale=1.05,
 202                on_activate_call=ba.WeakCall(self._switch_to_score, None),
 203            )
 204            self._store_button_widget = sbtn.get_button()
 205            ba.widget(
 206                edit=self._back_button,
 207                right_widget=self._league_rank_button_widget,
 208            )
 209            ba.widget(
 210                edit=self._league_rank_button_widget,
 211                left_widget=self._back_button,
 212            )
 213        else:
 214            self._league_rank_button = None
 215            self._store_button = None
 216            self._store_button_widget = None
 217            self._league_rank_button_widget = None
 218
 219        # Move our corner buttons dynamically to keep them out of the way of
 220        # the party icon :-(
 221        self._update_corner_button_positions()
 222        self._update_corner_button_positions_timer = ba.Timer(
 223            1.0,
 224            ba.WeakCall(self._update_corner_button_positions),
 225            repeat=True,
 226            timetype=ba.TimeType.REAL,
 227        )
 228
 229        self._last_tournament_query_time: float | None = None
 230        self._last_tournament_query_response_time: float | None = None
 231        self._doing_tournament_query = False
 232
 233        self._selected_campaign_level = cfg.get(
 234            'Selected Coop Campaign Level', None
 235        )
 236        self._selected_custom_level = cfg.get(
 237            'Selected Coop Custom Level', None
 238        )
 239
 240        # Don't want initial construction affecting our last-selected.
 241        self._do_selection_callbacks = False
 242        v = self._height - 95
 243        txt = ba.textwidget(
 244            parent=self._root_widget,
 245            position=(
 246                self._width * 0.5,
 247                v + 40 - (0 if uiscale is ba.UIScale.SMALL else 0),
 248            ),
 249            size=(0, 0),
 250            text=ba.Lstr(
 251                resource='playModes.singlePlayerCoopText',
 252                fallback_resource='playModes.coopText',
 253            ),
 254            h_align='center',
 255            color=app.ui.title_color,
 256            scale=1.5,
 257            maxwidth=500,
 258            v_align='center',
 259        )
 260
 261        if app.ui.use_toolbars and uiscale is ba.UIScale.SMALL:
 262            ba.textwidget(edit=txt, text='')
 263
 264        if self._back_button is not None:
 265            ba.buttonwidget(
 266                edit=self._back_button,
 267                button_type='backSmall',
 268                size=(60, 50),
 269                position=(
 270                    75 + x_inset,
 271                    self._height
 272                    - 87
 273                    - (4 if uiscale is ba.UIScale.SMALL else 0)
 274                    + 6,
 275                ),
 276                label=ba.charstr(ba.SpecialChar.BACK),
 277            )
 278
 279        self._selected_row = cfg.get('Selected Coop Row', None)
 280
 281        self.star_tex = ba.gettexture('star')
 282        self.lsbt = ba.getmodel('level_select_button_transparent')
 283        self.lsbo = ba.getmodel('level_select_button_opaque')
 284        self.a_outline_tex = ba.gettexture('achievementOutline')
 285        self.a_outline_model = ba.getmodel('achievementOutline')
 286
 287        self._scroll_width = self._width - (130 + 2 * x_inset)
 288        self._scroll_height = self._height - (
 289            190 if uiscale is ba.UIScale.SMALL and app.ui.use_toolbars else 160
 290        )
 291
 292        self._subcontainerwidth = 800.0
 293        self._subcontainerheight = 1400.0
 294
 295        self._scrollwidget = ba.scrollwidget(
 296            parent=self._root_widget,
 297            highlight=False,
 298            position=(65 + x_inset, 120)
 299            if uiscale is ba.UIScale.SMALL and app.ui.use_toolbars
 300            else (65 + x_inset, 70),
 301            size=(self._scroll_width, self._scroll_height),
 302            simple_culling_v=10.0,
 303            claims_left_right=True,
 304            claims_tab=True,
 305            selection_loops_to_parent=True,
 306        )
 307        self._subcontainer: ba.Widget | None = None
 308
 309        # Take note of our account state; we'll refresh later if this changes.
 310        self._account_state_num = ba.internal.get_v1_account_state_num()
 311
 312        # Same for fg/bg state.
 313        self._fg_state = app.fg_state
 314
 315        self._refresh()
 316        self._restore_state()
 317
 318        # Even though we might display cached tournament data immediately, we
 319        # don't consider it valid until we've pinged.
 320        # the server for an update
 321        self._tourney_data_up_to_date = False
 322
 323        # If we've got a cached tournament list for our account and info for
 324        # each one of those tournaments, go ahead and display it as a
 325        # starting point.
 326        if (
 327            app.accounts_v1.account_tournament_list is not None
 328            and app.accounts_v1.account_tournament_list[0]
 329            == ba.internal.get_v1_account_state_num()
 330            and all(
 331                t_id in app.accounts_v1.tournament_info
 332                for t_id in app.accounts_v1.account_tournament_list[1]
 333            )
 334        ):
 335            tourney_data = [
 336                app.accounts_v1.tournament_info[t_id]
 337                for t_id in app.accounts_v1.account_tournament_list[1]
 338            ]
 339            self._update_for_data(tourney_data)
 340
 341        # This will pull new data periodically, update timers, etc.
 342        self._update_timer = ba.Timer(
 343            1.0,
 344            ba.WeakCall(self._update),
 345            timetype=ba.TimeType.REAL,
 346            repeat=True,
 347        )
 348        self._update()
 349
 350    # noinspection PyUnresolvedReferences
 351    @staticmethod
 352    def _preload_modules() -> None:
 353        """Preload modules we use (called in bg thread)."""
 354        import bastd.ui.purchase as _unused1
 355        import bastd.ui.coop.gamebutton as _unused2
 356        import bastd.ui.confirm as _unused3
 357        import bastd.ui.account as _unused4
 358        import bastd.ui.league.rankwindow as _unused5
 359        import bastd.ui.store.browser as _unused6
 360        import bastd.ui.account.viewer as _unused7
 361        import bastd.ui.tournamentscores as _unused8
 362        import bastd.ui.tournamententry as _unused9
 363        import bastd.ui.play as _unused10
 364        import bastd.ui.coop.tournamentbutton as _unused11
 365
 366    def _update(self) -> None:
 367        # Do nothing if we've somehow outlived our actual UI.
 368        if not self._root_widget:
 369            return
 370
 371        cur_time = ba.time(ba.TimeType.REAL)
 372
 373        # If its been a while since we got a tournament update, consider the
 374        # data invalid (prevents us from joining tournaments if our internet
 375        # connection goes down for a while).
 376        if (
 377            self._last_tournament_query_response_time is None
 378            or ba.time(ba.TimeType.REAL)
 379            - self._last_tournament_query_response_time
 380            > 60.0 * 2
 381        ):
 382            self._tourney_data_up_to_date = False
 383
 384        # If our account state has changed, do a full request.
 385        account_state_num = ba.internal.get_v1_account_state_num()
 386        if account_state_num != self._account_state_num:
 387            self._account_state_num = account_state_num
 388            self._save_state()
 389            self._refresh()
 390
 391            # Also encourage a new tournament query since this will clear out
 392            # our current results.
 393            if not self._doing_tournament_query:
 394                self._last_tournament_query_time = None
 395
 396        # If we've been backgrounded/foregrounded, invalidate our
 397        # tournament entries (they will be refreshed below asap).
 398        if self._fg_state != ba.app.fg_state:
 399            self._tourney_data_up_to_date = False
 400
 401        # Send off a new tournament query if its been long enough or whatnot.
 402        if not self._doing_tournament_query and (
 403            self._last_tournament_query_time is None
 404            or cur_time - self._last_tournament_query_time > 30.0
 405            or self._fg_state != ba.app.fg_state
 406        ):
 407            self._fg_state = ba.app.fg_state
 408            self._last_tournament_query_time = cur_time
 409            self._doing_tournament_query = True
 410            ba.internal.tournament_query(
 411                args={'source': 'coop window refresh', 'numScores': 1},
 412                callback=ba.WeakCall(self._on_tournament_query_response),
 413            )
 414
 415        # Decrement time on our tournament buttons.
 416        ads_enabled = ba.internal.have_incentivized_ad()
 417        for tbtn in self._tournament_buttons:
 418            tbtn.time_remaining = max(0, tbtn.time_remaining - 1)
 419            if tbtn.time_remaining_value_text is not None:
 420                ba.textwidget(
 421                    edit=tbtn.time_remaining_value_text,
 422                    text=ba.timestring(
 423                        tbtn.time_remaining,
 424                        centi=False,
 425                        suppress_format_warning=True,
 426                    )
 427                    if (
 428                        tbtn.has_time_remaining
 429                        and self._tourney_data_up_to_date
 430                    )
 431                    else '-',
 432                )
 433
 434            # Also adjust the ad icon visibility.
 435            if tbtn.allow_ads and ba.internal.has_video_ads():
 436                ba.imagewidget(
 437                    edit=tbtn.entry_fee_ad_image,
 438                    opacity=1.0 if ads_enabled else 0.25,
 439                )
 440                ba.textwidget(
 441                    edit=tbtn.entry_fee_text_remaining,
 442                    color=(0.6, 0.6, 0.6, 1 if ads_enabled else 0.2),
 443                )
 444
 445        self._update_hard_mode_lock_image()
 446
 447    def _update_hard_mode_lock_image(self) -> None:
 448        try:
 449            ba.imagewidget(
 450                edit=self._hard_button_lock_image,
 451                opacity=0.0 if ba.app.accounts_v1.have_pro_options() else 1.0,
 452            )
 453        except Exception:
 454            ba.print_exception('Error updating campaign lock.')
 455
 456    def _update_for_data(self, data: list[dict[str, Any]] | None) -> None:
 457
 458        # If the number of tournaments or challenges in the data differs from
 459        # our current arrangement, refresh with the new number.
 460        if (data is None and self._tournament_button_count != 0) or (
 461            data is not None and (len(data) != self._tournament_button_count)
 462        ):
 463            self._tournament_button_count = len(data) if data is not None else 0
 464            ba.app.config['Tournament Rows'] = self._tournament_button_count
 465            self._refresh()
 466
 467        # Update all of our tourney buttons based on whats in data.
 468        for i, tbtn in enumerate(self._tournament_buttons):
 469            assert data is not None
 470            tbtn.update_for_data(data[i])
 471
 472    def _on_tournament_query_response(
 473        self, data: dict[str, Any] | None
 474    ) -> None:
 475        accounts = ba.app.accounts_v1
 476        if data is not None:
 477            tournament_data = data['t']  # This used to be the whole payload.
 478            self._last_tournament_query_response_time = ba.time(
 479                ba.TimeType.REAL
 480            )
 481        else:
 482            tournament_data = None
 483
 484        # Keep our cached tourney info up to date.
 485        if data is not None:
 486            self._tourney_data_up_to_date = True
 487            accounts.cache_tournament_info(tournament_data)
 488
 489            # Also cache the current tourney list/order for this account.
 490            accounts.account_tournament_list = (
 491                ba.internal.get_v1_account_state_num(),
 492                [e['tournamentID'] for e in tournament_data],
 493            )
 494
 495        self._doing_tournament_query = False
 496        self._update_for_data(tournament_data)
 497
 498    def _set_campaign_difficulty(self, difficulty: str) -> None:
 499        # pylint: disable=cyclic-import
 500        from bastd.ui.purchase import PurchaseWindow
 501
 502        if difficulty != self._campaign_difficulty:
 503            if (
 504                difficulty == 'hard'
 505                and not ba.app.accounts_v1.have_pro_options()
 506            ):
 507                PurchaseWindow(items=['pro'])
 508                return
 509            ba.playsound(ba.getsound('gunCocking'))
 510            if difficulty not in ('easy', 'hard'):
 511                print('ERROR: invalid campaign difficulty:', difficulty)
 512                difficulty = 'easy'
 513            self._campaign_difficulty = difficulty
 514            ba.internal.add_transaction(
 515                {
 516                    'type': 'SET_MISC_VAL',
 517                    'name': 'campaignDifficulty',
 518                    'value': difficulty,
 519                }
 520            )
 521            self._refresh_campaign_row()
 522        else:
 523            ba.playsound(ba.getsound('click01'))
 524
 525    def _refresh_campaign_row(self) -> None:
 526        # pylint: disable=too-many-locals
 527        # pylint: disable=cyclic-import
 528        from ba.internal import getcampaign
 529        from bastd.ui.coop.gamebutton import GameButton
 530
 531        parent_widget = self._campaign_sub_container
 532
 533        # Clear out anything in the parent widget already.
 534        for child in parent_widget.get_children():
 535            child.delete()
 536
 537        next_widget_down = self._tournament_info_button
 538
 539        h = 0
 540        v2 = -2
 541        sel_color = (0.75, 0.85, 0.5)
 542        sel_color_hard = (0.4, 0.7, 0.2)
 543        un_sel_color = (0.5, 0.5, 0.5)
 544        sel_textcolor = (2, 2, 0.8)
 545        un_sel_textcolor = (0.6, 0.6, 0.6)
 546        self._easy_button = ba.buttonwidget(
 547            parent=parent_widget,
 548            position=(h + 30, v2 + 105),
 549            size=(120, 70),
 550            label=ba.Lstr(resource='difficultyEasyText'),
 551            button_type='square',
 552            autoselect=True,
 553            enable_sound=False,
 554            on_activate_call=ba.Call(self._set_campaign_difficulty, 'easy'),
 555            on_select_call=ba.Call(self.sel_change, 'campaign', 'easyButton'),
 556            color=sel_color
 557            if self._campaign_difficulty == 'easy'
 558            else un_sel_color,
 559            textcolor=sel_textcolor
 560            if self._campaign_difficulty == 'easy'
 561            else un_sel_textcolor,
 562        )
 563        ba.widget(edit=self._easy_button, show_buffer_left=100)
 564        if self._selected_campaign_level == 'easyButton':
 565            ba.containerwidget(
 566                edit=parent_widget,
 567                selected_child=self._easy_button,
 568                visible_child=self._easy_button,
 569            )
 570        lock_tex = ba.gettexture('lock')
 571
 572        self._hard_button = ba.buttonwidget(
 573            parent=parent_widget,
 574            position=(h + 30, v2 + 32),
 575            size=(120, 70),
 576            label=ba.Lstr(resource='difficultyHardText'),
 577            button_type='square',
 578            autoselect=True,
 579            enable_sound=False,
 580            on_activate_call=ba.Call(self._set_campaign_difficulty, 'hard'),
 581            on_select_call=ba.Call(self.sel_change, 'campaign', 'hardButton'),
 582            color=sel_color_hard
 583            if self._campaign_difficulty == 'hard'
 584            else un_sel_color,
 585            textcolor=sel_textcolor
 586            if self._campaign_difficulty == 'hard'
 587            else un_sel_textcolor,
 588        )
 589        self._hard_button_lock_image = ba.imagewidget(
 590            parent=parent_widget,
 591            size=(30, 30),
 592            draw_controller=self._hard_button,
 593            position=(h + 30 - 10, v2 + 32 + 70 - 35),
 594            texture=lock_tex,
 595        )
 596        self._update_hard_mode_lock_image()
 597        ba.widget(edit=self._hard_button, show_buffer_left=100)
 598        if self._selected_campaign_level == 'hardButton':
 599            ba.containerwidget(
 600                edit=parent_widget,
 601                selected_child=self._hard_button,
 602                visible_child=self._hard_button,
 603            )
 604
 605        ba.widget(edit=self._hard_button, down_widget=next_widget_down)
 606        h_spacing = 200
 607        campaign_buttons = []
 608        if self._campaign_difficulty == 'easy':
 609            campaignname = 'Easy'
 610        else:
 611            campaignname = 'Default'
 612        items = [
 613            campaignname + ':Onslaught Training',
 614            campaignname + ':Rookie Onslaught',
 615            campaignname + ':Rookie Football',
 616            campaignname + ':Pro Onslaught',
 617            campaignname + ':Pro Football',
 618            campaignname + ':Pro Runaround',
 619            campaignname + ':Uber Onslaught',
 620            campaignname + ':Uber Football',
 621            campaignname + ':Uber Runaround',
 622        ]
 623        items += [campaignname + ':The Last Stand']
 624        if self._selected_campaign_level is None:
 625            self._selected_campaign_level = items[0]
 626        h = 150
 627        for i in items:
 628            is_last_sel = i == self._selected_campaign_level
 629            campaign_buttons.append(
 630                GameButton(
 631                    self, parent_widget, i, h, v2, is_last_sel, 'campaign'
 632                ).get_button()
 633            )
 634            h += h_spacing
 635
 636        ba.widget(edit=campaign_buttons[0], left_widget=self._easy_button)
 637
 638        if self._back_button is not None:
 639            ba.widget(edit=self._easy_button, up_widget=self._back_button)
 640            for btn in campaign_buttons:
 641                ba.widget(
 642                    edit=btn,
 643                    up_widget=self._back_button,
 644                    down_widget=next_widget_down,
 645                )
 646
 647        # Update our existing percent-complete text.
 648        campaign = getcampaign(campaignname)
 649        levels = campaign.levels
 650        levels_complete = sum((1 if l.complete else 0) for l in levels)
 651
 652        # Last level cant be completed; hence the -1.
 653        progress = min(1.0, float(levels_complete) / (len(levels) - 1))
 654        p_str = str(int(progress * 100.0)) + '%'
 655
 656        self._campaign_percent_text = ba.textwidget(
 657            edit=self._campaign_percent_text,
 658            text=ba.Lstr(
 659                value='${C} (${P})',
 660                subs=[
 661                    ('${C}', ba.Lstr(resource=self._r + '.campaignText')),
 662                    ('${P}', p_str),
 663                ],
 664            ),
 665        )
 666
 667    def _on_tournament_info_press(self) -> None:
 668        # pylint: disable=cyclic-import
 669        from bastd.ui.confirm import ConfirmWindow
 670
 671        txt = ba.Lstr(resource=self._r + '.tournamentInfoText')
 672        ConfirmWindow(
 673            txt,
 674            cancel_button=False,
 675            width=550,
 676            height=260,
 677            origin_widget=self._tournament_info_button,
 678        )
 679
 680    def _refresh(self) -> None:
 681        # pylint: disable=too-many-statements
 682        # pylint: disable=too-many-branches
 683        # pylint: disable=too-many-locals
 684        # pylint: disable=cyclic-import
 685        from bastd.ui.coop.gamebutton import GameButton
 686        from bastd.ui.coop.tournamentbutton import TournamentButton
 687
 688        # (Re)create the sub-container if need be.
 689        if self._subcontainer is not None:
 690            self._subcontainer.delete()
 691
 692        tourney_row_height = 200
 693        self._subcontainerheight = (
 694            620 + self._tournament_button_count * tourney_row_height
 695        )
 696
 697        self._subcontainer = ba.containerwidget(
 698            parent=self._scrollwidget,
 699            size=(self._subcontainerwidth, self._subcontainerheight),
 700            background=False,
 701            claims_left_right=True,
 702            claims_tab=True,
 703            selection_loops_to_parent=True,
 704        )
 705
 706        ba.containerwidget(
 707            edit=self._root_widget, selected_child=self._scrollwidget
 708        )
 709        if self._back_button is not None:
 710            ba.containerwidget(
 711                edit=self._root_widget, cancel_button=self._back_button
 712            )
 713
 714        w_parent = self._subcontainer
 715        h_base = 6
 716
 717        v = self._subcontainerheight - 73
 718
 719        self._campaign_percent_text = ba.textwidget(
 720            parent=w_parent,
 721            position=(h_base + 27, v + 30),
 722            size=(0, 0),
 723            text='',
 724            h_align='left',
 725            v_align='center',
 726            color=ba.app.ui.title_color,
 727            scale=1.1,
 728        )
 729
 730        row_v_show_buffer = 100
 731        v -= 198
 732
 733        h_scroll = ba.hscrollwidget(
 734            parent=w_parent,
 735            size=(self._scroll_width - 10, 205),
 736            position=(-5, v),
 737            simple_culling_h=70,
 738            highlight=False,
 739            border_opacity=0.0,
 740            color=(0.45, 0.4, 0.5),
 741            on_select_call=lambda: self._on_row_selected('campaign'),
 742        )
 743        self._campaign_h_scroll = h_scroll
 744        ba.widget(
 745            edit=h_scroll,
 746            show_buffer_top=row_v_show_buffer,
 747            show_buffer_bottom=row_v_show_buffer,
 748            autoselect=True,
 749        )
 750        if self._selected_row == 'campaign':
 751            ba.containerwidget(
 752                edit=w_parent, selected_child=h_scroll, visible_child=h_scroll
 753            )
 754        ba.containerwidget(edit=h_scroll, claims_left_right=True)
 755        self._campaign_sub_container = ba.containerwidget(
 756            parent=h_scroll, size=(180 + 200 * 10, 200), background=False
 757        )
 758
 759        # Tournaments
 760
 761        self._tournament_buttons: list[TournamentButton] = []
 762
 763        v -= 53
 764        # FIXME shouldn't use hard-coded strings here.
 765        txt = ba.Lstr(
 766            resource='tournamentsText', fallback_resource='tournamentText'
 767        ).evaluate()
 768        t_width = ba.internal.get_string_width(txt, suppress_warning=True)
 769        ba.textwidget(
 770            parent=w_parent,
 771            position=(h_base + 27, v + 30),
 772            size=(0, 0),
 773            text=txt,
 774            h_align='left',
 775            v_align='center',
 776            color=ba.app.ui.title_color,
 777            scale=1.1,
 778        )
 779        self._tournament_info_button = ba.buttonwidget(
 780            parent=w_parent,
 781            label='?',
 782            size=(20, 20),
 783            text_scale=0.6,
 784            position=(h_base + 27 + t_width * 1.1 + 15, v + 18),
 785            button_type='square',
 786            color=(0.6, 0.5, 0.65),
 787            textcolor=(0.7, 0.6, 0.75),
 788            autoselect=True,
 789            up_widget=self._campaign_h_scroll,
 790            on_activate_call=self._on_tournament_info_press,
 791        )
 792        ba.widget(
 793            edit=self._tournament_info_button,
 794            left_widget=self._tournament_info_button,
 795            right_widget=self._tournament_info_button,
 796        )
 797
 798        # Say 'unavailable' if there are zero tournaments, and if we're not
 799        # signed in add that as well (that's probably why we see
 800        # no tournaments).
 801        if self._tournament_button_count == 0:
 802            unavailable_text = ba.Lstr(resource='unavailableText')
 803            if ba.internal.get_v1_account_state() != 'signed_in':
 804                unavailable_text = ba.Lstr(
 805                    value='${A} (${B})',
 806                    subs=[
 807                        ('${A}', unavailable_text),
 808                        ('${B}', ba.Lstr(resource='notSignedInText')),
 809                    ],
 810                )
 811            ba.textwidget(
 812                parent=w_parent,
 813                position=(h_base + 47, v),
 814                size=(0, 0),
 815                text=unavailable_text,
 816                h_align='left',
 817                v_align='center',
 818                color=ba.app.ui.title_color,
 819                scale=0.9,
 820            )
 821            v -= 40
 822        v -= 198
 823
 824        tournament_h_scroll = None
 825        if self._tournament_button_count > 0:
 826            for i in range(self._tournament_button_count):
 827                tournament_h_scroll = h_scroll = ba.hscrollwidget(
 828                    parent=w_parent,
 829                    size=(self._scroll_width - 10, 205),
 830                    position=(-5, v),
 831                    highlight=False,
 832                    border_opacity=0.0,
 833                    color=(0.45, 0.4, 0.5),
 834                    on_select_call=ba.Call(
 835                        self._on_row_selected, 'tournament' + str(i + 1)
 836                    ),
 837                )
 838                ba.widget(
 839                    edit=h_scroll,
 840                    show_buffer_top=row_v_show_buffer,
 841                    show_buffer_bottom=row_v_show_buffer,
 842                    autoselect=True,
 843                )
 844                if self._selected_row == 'tournament' + str(i + 1):
 845                    ba.containerwidget(
 846                        edit=w_parent,
 847                        selected_child=h_scroll,
 848                        visible_child=h_scroll,
 849                    )
 850                ba.containerwidget(edit=h_scroll, claims_left_right=True)
 851                sc2 = ba.containerwidget(
 852                    parent=h_scroll,
 853                    size=(self._scroll_width - 24, 200),
 854                    background=False,
 855                )
 856                h = 0
 857                v2 = -2
 858                is_last_sel = True
 859                self._tournament_buttons.append(
 860                    TournamentButton(
 861                        sc2,
 862                        h,
 863                        v2,
 864                        is_last_sel,
 865                        on_pressed=ba.WeakCall(self.run_tournament),
 866                    )
 867                )
 868                v -= 200
 869
 870        # Custom Games. (called 'Practice' in UI these days).
 871        v -= 50
 872        ba.textwidget(
 873            parent=w_parent,
 874            position=(h_base + 27, v + 30 + 198),
 875            size=(0, 0),
 876            text=ba.Lstr(
 877                resource='practiceText',
 878                fallback_resource='coopSelectWindow.customText',
 879            ),
 880            h_align='left',
 881            v_align='center',
 882            color=ba.app.ui.title_color,
 883            scale=1.1,
 884        )
 885
 886        items = [
 887            'Challenges:Infinite Onslaught',
 888            'Challenges:Infinite Runaround',
 889            'Challenges:Ninja Fight',
 890            'Challenges:Pro Ninja Fight',
 891            'Challenges:Meteor Shower',
 892            'Challenges:Target Practice B',
 893            'Challenges:Target Practice',
 894        ]
 895
 896        # Show easter-egg-hunt either if its easter or we own it.
 897        if ba.internal.get_v1_account_misc_read_val(
 898            'easter', False
 899        ) or ba.internal.get_purchased('games.easter_egg_hunt'):
 900            items = [
 901                'Challenges:Easter Egg Hunt',
 902                'Challenges:Pro Easter Egg Hunt',
 903            ] + items
 904
 905        # If we've defined custom games, put them at the beginning.
 906        if ba.app.custom_coop_practice_games:
 907            items = ba.app.custom_coop_practice_games + items
 908
 909        self._custom_h_scroll = custom_h_scroll = h_scroll = ba.hscrollwidget(
 910            parent=w_parent,
 911            size=(self._scroll_width - 10, 205),
 912            position=(-5, v),
 913            highlight=False,
 914            border_opacity=0.0,
 915            color=(0.45, 0.4, 0.5),
 916            on_select_call=ba.Call(self._on_row_selected, 'custom'),
 917        )
 918        ba.widget(
 919            edit=h_scroll,
 920            show_buffer_top=row_v_show_buffer,
 921            show_buffer_bottom=1.5 * row_v_show_buffer,
 922            autoselect=True,
 923        )
 924        if self._selected_row == 'custom':
 925            ba.containerwidget(
 926                edit=w_parent, selected_child=h_scroll, visible_child=h_scroll
 927            )
 928        ba.containerwidget(edit=h_scroll, claims_left_right=True)
 929        sc2 = ba.containerwidget(
 930            parent=h_scroll,
 931            size=(max(self._scroll_width - 24, 30 + 200 * len(items)), 200),
 932            background=False,
 933        )
 934        h_spacing = 200
 935        self._custom_buttons: list[GameButton] = []
 936        h = 0
 937        v2 = -2
 938        for item in items:
 939            is_last_sel = item == self._selected_custom_level
 940            self._custom_buttons.append(
 941                GameButton(self, sc2, item, h, v2, is_last_sel, 'custom')
 942            )
 943            h += h_spacing
 944
 945        # We can't fill in our campaign row until tourney buttons are in place.
 946        # (for wiring up)
 947        self._refresh_campaign_row()
 948
 949        for i, tbutton in enumerate(self._tournament_buttons):
 950            ba.widget(
 951                edit=tbutton.button,
 952                up_widget=self._tournament_info_button
 953                if i == 0
 954                else self._tournament_buttons[i - 1].button,
 955                down_widget=self._tournament_buttons[(i + 1)].button
 956                if i + 1 < len(self._tournament_buttons)
 957                else custom_h_scroll,
 958            )
 959            ba.widget(
 960                edit=tbutton.more_scores_button,
 961                down_widget=self._tournament_buttons[
 962                    (i + 1)
 963                ].current_leader_name_text
 964                if i + 1 < len(self._tournament_buttons)
 965                else custom_h_scroll,
 966            )
 967            ba.widget(
 968                edit=tbutton.current_leader_name_text,
 969                up_widget=self._tournament_info_button
 970                if i == 0
 971                else self._tournament_buttons[i - 1].more_scores_button,
 972            )
 973
 974        for btn in self._custom_buttons:
 975            try:
 976                ba.widget(
 977                    edit=btn.get_button(),
 978                    up_widget=tournament_h_scroll
 979                    if self._tournament_buttons
 980                    else self._tournament_info_button,
 981                )
 982            except Exception:
 983                ba.print_exception('Error wiring up custom buttons.')
 984
 985        if self._back_button is not None:
 986            ba.buttonwidget(edit=self._back_button, on_activate_call=self._back)
 987        else:
 988            ba.containerwidget(
 989                edit=self._root_widget, on_cancel_call=self._back
 990            )
 991
 992        # There's probably several 'onSelected' callbacks pushed onto the
 993        # event queue.. we need to push ours too so we're enabled *after* them.
 994        ba.pushcall(self._enable_selectable_callback)
 995
 996    def _on_row_selected(self, row: str) -> None:
 997        if self._do_selection_callbacks:
 998            if self._selected_row != row:
 999                self._selected_row = row
1000
1001    def _enable_selectable_callback(self) -> None:
1002        self._do_selection_callbacks = True
1003
1004    def _switch_to_league_rankings(self) -> None:
1005        # pylint: disable=cyclic-import
1006        from bastd.ui.account import show_sign_in_prompt
1007        from bastd.ui.league.rankwindow import LeagueRankWindow
1008
1009        if ba.internal.get_v1_account_state() != 'signed_in':
1010            show_sign_in_prompt()
1011            return
1012        self._save_state()
1013        ba.containerwidget(edit=self._root_widget, transition='out_left')
1014        assert self._league_rank_button is not None
1015        ba.app.ui.set_main_menu_window(
1016            LeagueRankWindow(
1017                origin_widget=self._league_rank_button.get_button()
1018            ).get_root_widget()
1019        )
1020
1021    def _switch_to_score(
1022        self,
1023        show_tab: StoreBrowserWindow.TabID
1024        | None = StoreBrowserWindow.TabID.EXTRAS,
1025    ) -> None:
1026        # pylint: disable=cyclic-import
1027        from bastd.ui.account import show_sign_in_prompt
1028
1029        if ba.internal.get_v1_account_state() != 'signed_in':
1030            show_sign_in_prompt()
1031            return
1032        self._save_state()
1033        ba.containerwidget(edit=self._root_widget, transition='out_left')
1034        assert self._store_button is not None
1035        ba.app.ui.set_main_menu_window(
1036            StoreBrowserWindow(
1037                origin_widget=self._store_button.get_button(),
1038                show_tab=show_tab,
1039                back_location='CoopBrowserWindow',
1040            ).get_root_widget()
1041        )
1042
1043    def is_tourney_data_up_to_date(self) -> bool:
1044        """Return whether our tourney data is up to date."""
1045        return self._tourney_data_up_to_date
1046
1047    def run_game(self, game: str) -> None:
1048        """Run the provided game."""
1049        # pylint: disable=too-many-branches
1050        # pylint: disable=cyclic-import
1051        from bastd.ui.confirm import ConfirmWindow
1052        from bastd.ui.purchase import PurchaseWindow
1053        from bastd.ui.account import show_sign_in_prompt
1054
1055        args: dict[str, Any] = {}
1056
1057        if game == 'Easy:The Last Stand':
1058            ConfirmWindow(
1059                ba.Lstr(
1060                    resource='difficultyHardUnlockOnlyText',
1061                    fallback_resource='difficultyHardOnlyText',
1062                ),
1063                cancel_button=False,
1064                width=460,
1065                height=130,
1066            )
1067            return
1068
1069        # Infinite onslaught/runaround require pro; bring up a store link
1070        # if need be.
1071        if (
1072            game
1073            in (
1074                'Challenges:Infinite Runaround',
1075                'Challenges:Infinite Onslaught',
1076            )
1077            and not ba.app.accounts_v1.have_pro()
1078        ):
1079            if ba.internal.get_v1_account_state() != 'signed_in':
1080                show_sign_in_prompt()
1081            else:
1082                PurchaseWindow(items=['pro'])
1083            return
1084
1085        required_purchase: str | None
1086        if game in ['Challenges:Meteor Shower']:
1087            required_purchase = 'games.meteor_shower'
1088        elif game in [
1089            'Challenges:Target Practice',
1090            'Challenges:Target Practice B',
1091        ]:
1092            required_purchase = 'games.target_practice'
1093        elif game in ['Challenges:Ninja Fight']:
1094            required_purchase = 'games.ninja_fight'
1095        elif game in ['Challenges:Pro Ninja Fight']:
1096            required_purchase = 'games.ninja_fight'
1097        elif game in [
1098            'Challenges:Easter Egg Hunt',
1099            'Challenges:Pro Easter Egg Hunt',
1100        ]:
1101            required_purchase = 'games.easter_egg_hunt'
1102        else:
1103            required_purchase = None
1104
1105        if required_purchase is not None and not ba.internal.get_purchased(
1106            required_purchase
1107        ):
1108            if ba.internal.get_v1_account_state() != 'signed_in':
1109                show_sign_in_prompt()
1110            else:
1111                PurchaseWindow(items=[required_purchase])
1112            return
1113
1114        self._save_state()
1115
1116        if ba.app.launch_coop_game(game, args=args):
1117            ba.containerwidget(edit=self._root_widget, transition='out_left')
1118
1119    def run_tournament(self, tournament_button: TournamentButton) -> None:
1120        """Run the provided tournament game."""
1121        from bastd.ui.account import show_sign_in_prompt
1122        from bastd.ui.tournamententry import TournamentEntryWindow
1123
1124        if ba.internal.get_v1_account_state() != 'signed_in':
1125            show_sign_in_prompt()
1126            return
1127
1128        if ba.internal.workspaces_in_use():
1129            ba.screenmessage(
1130                ba.Lstr(resource='tournamentsDisabledWorkspaceText'),
1131                color=(1, 0, 0),
1132            )
1133            ba.playsound(ba.getsound('error'))
1134            return
1135
1136        if not self._tourney_data_up_to_date:
1137            ba.screenmessage(
1138                ba.Lstr(resource='tournamentCheckingStateText'), color=(1, 1, 0)
1139            )
1140            ba.playsound(ba.getsound('error'))
1141            return
1142
1143        if tournament_button.tournament_id is None:
1144            ba.screenmessage(
1145                ba.Lstr(resource='internal.unavailableNoConnectionText'),
1146                color=(1, 0, 0),
1147            )
1148            ba.playsound(ba.getsound('error'))
1149            return
1150
1151        if tournament_button.required_league is not None:
1152            ba.screenmessage(
1153                ba.Lstr(
1154                    resource='league.tournamentLeagueText',
1155                    subs=[
1156                        (
1157                            '${NAME}',
1158                            ba.Lstr(
1159                                translate=(
1160                                    'leagueNames',
1161                                    tournament_button.required_league,
1162                                )
1163                            ),
1164                        )
1165                    ],
1166                ),
1167                color=(1, 0, 0),
1168            )
1169            ba.playsound(ba.getsound('error'))
1170            return
1171
1172        if tournament_button.time_remaining <= 0:
1173            ba.screenmessage(
1174                ba.Lstr(resource='tournamentEndedText'), color=(1, 0, 0)
1175            )
1176            ba.playsound(ba.getsound('error'))
1177            return
1178
1179        self._save_state()
1180
1181        assert tournament_button.tournament_id is not None
1182        TournamentEntryWindow(
1183            tournament_id=tournament_button.tournament_id,
1184            position=tournament_button.button.get_screen_space_center(),
1185        )
1186
1187    def _back(self) -> None:
1188        # pylint: disable=cyclic-import
1189        from bastd.ui.play import PlayWindow
1190
1191        # If something is selected, store it.
1192        self._save_state()
1193        ba.containerwidget(
1194            edit=self._root_widget, transition=self._transition_out
1195        )
1196        ba.app.ui.set_main_menu_window(
1197            PlayWindow(transition='in_left').get_root_widget()
1198        )
1199
1200    def _save_state(self) -> None:
1201        cfg = ba.app.config
1202        try:
1203            sel = self._root_widget.get_selected_child()
1204            if sel == self._back_button:
1205                sel_name = 'Back'
1206            elif sel == self._store_button_widget:
1207                sel_name = 'Store'
1208            elif sel == self._league_rank_button_widget:
1209                sel_name = 'PowerRanking'
1210            elif sel == self._scrollwidget:
1211                sel_name = 'Scroll'
1212            else:
1213                raise ValueError('unrecognized selection')
1214            ba.app.ui.window_states[type(self)] = {'sel_name': sel_name}
1215        except Exception:
1216            ba.print_exception(f'Error saving state for {self}.')
1217
1218        cfg['Selected Coop Row'] = self._selected_row
1219        cfg['Selected Coop Custom Level'] = self._selected_custom_level
1220        cfg['Selected Coop Campaign Level'] = self._selected_campaign_level
1221        cfg.commit()
1222
1223    def _restore_state(self) -> None:
1224        try:
1225            sel_name = ba.app.ui.window_states.get(type(self), {}).get(
1226                'sel_name'
1227            )
1228            if sel_name == 'Back':
1229                sel = self._back_button
1230            elif sel_name == 'Scroll':
1231                sel = self._scrollwidget
1232            elif sel_name == 'PowerRanking':
1233                sel = self._league_rank_button_widget
1234            elif sel_name == 'Store':
1235                sel = self._store_button_widget
1236            else:
1237                sel = self._scrollwidget
1238            ba.containerwidget(edit=self._root_widget, selected_child=sel)
1239        except Exception:
1240            ba.print_exception(f'Error restoring state for {self}.')
1241
1242    def sel_change(self, row: str, game: str) -> None:
1243        """(internal)"""
1244        if self._do_selection_callbacks:
1245            if row == 'custom':
1246                self._selected_custom_level = game
1247            elif row == 'campaign':
1248                self._selected_campaign_level = game

Window for browsing co-op levels/games/etc.

CoopBrowserWindow( transition: str | None = 'in_right', origin_widget: _ba.Widget | None = None)
 54    def __init__(
 55        self,
 56        transition: str | None = 'in_right',
 57        origin_widget: ba.Widget | None = None,
 58    ):
 59        # pylint: disable=too-many-statements
 60        # pylint: disable=cyclic-import
 61        import threading
 62
 63        # Preload some modules we use in a background thread so we won't
 64        # have a visual hitch when the user taps them.
 65        threading.Thread(target=self._preload_modules).start()
 66
 67        ba.set_analytics_screen('Coop Window')
 68
 69        app = ba.app
 70        cfg = app.config
 71
 72        # Quick note to players that tourneys won't work in ballistica
 73        # core builds. (need to split the word so it won't get subbed out)
 74        if 'ballistica' + 'core' == ba.internal.appname():
 75            ba.timer(
 76                1.0,
 77                lambda: ba.screenmessage(
 78                    ba.Lstr(resource='noTournamentsInTestBuildText'),
 79                    color=(1, 1, 0),
 80                ),
 81                timetype=ba.TimeType.REAL,
 82            )
 83
 84        # If they provided an origin-widget, scale up from that.
 85        scale_origin: tuple[float, float] | None
 86        if origin_widget is not None:
 87            self._transition_out = 'out_scale'
 88            scale_origin = origin_widget.get_screen_space_center()
 89            transition = 'in_scale'
 90        else:
 91            self._transition_out = 'out_right'
 92            scale_origin = None
 93
 94        # Try to recreate the same number of buttons we had last time so our
 95        # re-selection code works.
 96        self._tournament_button_count = app.config.get('Tournament Rows', 0)
 97        assert isinstance(self._tournament_button_count, int)
 98
 99        self._easy_button: ba.Widget | None = None
100        self._hard_button: ba.Widget | None = None
101        self._hard_button_lock_image: ba.Widget | None = None
102        self._campaign_percent_text: ba.Widget | None = None
103
104        uiscale = ba.app.ui.uiscale
105        self._width = 1320 if uiscale is ba.UIScale.SMALL else 1120
106        self._x_inset = x_inset = 100 if uiscale is ba.UIScale.SMALL else 0
107        self._height = (
108            657
109            if uiscale is ba.UIScale.SMALL
110            else 730
111            if uiscale is ba.UIScale.MEDIUM
112            else 800
113        )
114        app.ui.set_main_menu_location('Coop Select')
115        self._r = 'coopSelectWindow'
116        top_extra = 20 if uiscale is ba.UIScale.SMALL else 0
117
118        self._tourney_data_up_to_date = False
119
120        self._campaign_difficulty = ba.internal.get_v1_account_misc_val(
121            'campaignDifficulty', 'easy'
122        )
123
124        super().__init__(
125            root_widget=ba.containerwidget(
126                size=(self._width, self._height + top_extra),
127                toolbar_visibility='menu_full',
128                scale_origin_stack_offset=scale_origin,
129                stack_offset=(
130                    (0, -15)
131                    if uiscale is ba.UIScale.SMALL
132                    else (0, 0)
133                    if uiscale is ba.UIScale.MEDIUM
134                    else (0, 0)
135                ),
136                transition=transition,
137                scale=(
138                    1.2
139                    if uiscale is ba.UIScale.SMALL
140                    else 0.8
141                    if uiscale is ba.UIScale.MEDIUM
142                    else 0.75
143                ),
144            )
145        )
146
147        if app.ui.use_toolbars and uiscale is ba.UIScale.SMALL:
148            self._back_button = None
149        else:
150            self._back_button = ba.buttonwidget(
151                parent=self._root_widget,
152                position=(
153                    75 + x_inset,
154                    self._height
155                    - 87
156                    - (4 if uiscale is ba.UIScale.SMALL else 0),
157                ),
158                size=(120, 60),
159                scale=1.2,
160                autoselect=True,
161                label=ba.Lstr(resource='backText'),
162                button_type='back',
163            )
164
165        self._league_rank_button: LeagueRankButton | None
166        self._store_button: StoreButton | None
167        self._store_button_widget: ba.Widget | None
168        self._league_rank_button_widget: ba.Widget | None
169
170        if not app.ui.use_toolbars:
171            prb = self._league_rank_button = LeagueRankButton(
172                parent=self._root_widget,
173                position=(
174                    self._width - (282 + x_inset),
175                    self._height
176                    - 85
177                    - (4 if uiscale is ba.UIScale.SMALL else 0),
178                ),
179                size=(100, 60),
180                color=(0.4, 0.4, 0.9),
181                textcolor=(0.9, 0.9, 2.0),
182                scale=1.05,
183                on_activate_call=ba.WeakCall(self._switch_to_league_rankings),
184            )
185            self._league_rank_button_widget = prb.get_button()
186
187            sbtn = self._store_button = StoreButton(
188                parent=self._root_widget,
189                position=(
190                    self._width - (170 + x_inset),
191                    self._height
192                    - 85
193                    - (4 if uiscale is ba.UIScale.SMALL else 0),
194                ),
195                size=(100, 60),
196                color=(0.6, 0.4, 0.7),
197                show_tickets=True,
198                button_type='square',
199                sale_scale=0.85,
200                textcolor=(0.9, 0.7, 1.0),
201                scale=1.05,
202                on_activate_call=ba.WeakCall(self._switch_to_score, None),
203            )
204            self._store_button_widget = sbtn.get_button()
205            ba.widget(
206                edit=self._back_button,
207                right_widget=self._league_rank_button_widget,
208            )
209            ba.widget(
210                edit=self._league_rank_button_widget,
211                left_widget=self._back_button,
212            )
213        else:
214            self._league_rank_button = None
215            self._store_button = None
216            self._store_button_widget = None
217            self._league_rank_button_widget = None
218
219        # Move our corner buttons dynamically to keep them out of the way of
220        # the party icon :-(
221        self._update_corner_button_positions()
222        self._update_corner_button_positions_timer = ba.Timer(
223            1.0,
224            ba.WeakCall(self._update_corner_button_positions),
225            repeat=True,
226            timetype=ba.TimeType.REAL,
227        )
228
229        self._last_tournament_query_time: float | None = None
230        self._last_tournament_query_response_time: float | None = None
231        self._doing_tournament_query = False
232
233        self._selected_campaign_level = cfg.get(
234            'Selected Coop Campaign Level', None
235        )
236        self._selected_custom_level = cfg.get(
237            'Selected Coop Custom Level', None
238        )
239
240        # Don't want initial construction affecting our last-selected.
241        self._do_selection_callbacks = False
242        v = self._height - 95
243        txt = ba.textwidget(
244            parent=self._root_widget,
245            position=(
246                self._width * 0.5,
247                v + 40 - (0 if uiscale is ba.UIScale.SMALL else 0),
248            ),
249            size=(0, 0),
250            text=ba.Lstr(
251                resource='playModes.singlePlayerCoopText',
252                fallback_resource='playModes.coopText',
253            ),
254            h_align='center',
255            color=app.ui.title_color,
256            scale=1.5,
257            maxwidth=500,
258            v_align='center',
259        )
260
261        if app.ui.use_toolbars and uiscale is ba.UIScale.SMALL:
262            ba.textwidget(edit=txt, text='')
263
264        if self._back_button is not None:
265            ba.buttonwidget(
266                edit=self._back_button,
267                button_type='backSmall',
268                size=(60, 50),
269                position=(
270                    75 + x_inset,
271                    self._height
272                    - 87
273                    - (4 if uiscale is ba.UIScale.SMALL else 0)
274                    + 6,
275                ),
276                label=ba.charstr(ba.SpecialChar.BACK),
277            )
278
279        self._selected_row = cfg.get('Selected Coop Row', None)
280
281        self.star_tex = ba.gettexture('star')
282        self.lsbt = ba.getmodel('level_select_button_transparent')
283        self.lsbo = ba.getmodel('level_select_button_opaque')
284        self.a_outline_tex = ba.gettexture('achievementOutline')
285        self.a_outline_model = ba.getmodel('achievementOutline')
286
287        self._scroll_width = self._width - (130 + 2 * x_inset)
288        self._scroll_height = self._height - (
289            190 if uiscale is ba.UIScale.SMALL and app.ui.use_toolbars else 160
290        )
291
292        self._subcontainerwidth = 800.0
293        self._subcontainerheight = 1400.0
294
295        self._scrollwidget = ba.scrollwidget(
296            parent=self._root_widget,
297            highlight=False,
298            position=(65 + x_inset, 120)
299            if uiscale is ba.UIScale.SMALL and app.ui.use_toolbars
300            else (65 + x_inset, 70),
301            size=(self._scroll_width, self._scroll_height),
302            simple_culling_v=10.0,
303            claims_left_right=True,
304            claims_tab=True,
305            selection_loops_to_parent=True,
306        )
307        self._subcontainer: ba.Widget | None = None
308
309        # Take note of our account state; we'll refresh later if this changes.
310        self._account_state_num = ba.internal.get_v1_account_state_num()
311
312        # Same for fg/bg state.
313        self._fg_state = app.fg_state
314
315        self._refresh()
316        self._restore_state()
317
318        # Even though we might display cached tournament data immediately, we
319        # don't consider it valid until we've pinged.
320        # the server for an update
321        self._tourney_data_up_to_date = False
322
323        # If we've got a cached tournament list for our account and info for
324        # each one of those tournaments, go ahead and display it as a
325        # starting point.
326        if (
327            app.accounts_v1.account_tournament_list is not None
328            and app.accounts_v1.account_tournament_list[0]
329            == ba.internal.get_v1_account_state_num()
330            and all(
331                t_id in app.accounts_v1.tournament_info
332                for t_id in app.accounts_v1.account_tournament_list[1]
333            )
334        ):
335            tourney_data = [
336                app.accounts_v1.tournament_info[t_id]
337                for t_id in app.accounts_v1.account_tournament_list[1]
338            ]
339            self._update_for_data(tourney_data)
340
341        # This will pull new data periodically, update timers, etc.
342        self._update_timer = ba.Timer(
343            1.0,
344            ba.WeakCall(self._update),
345            timetype=ba.TimeType.REAL,
346            repeat=True,
347        )
348        self._update()
def is_tourney_data_up_to_date(self) -> bool:
1043    def is_tourney_data_up_to_date(self) -> bool:
1044        """Return whether our tourney data is up to date."""
1045        return self._tourney_data_up_to_date

Return whether our tourney data is up to date.

def run_game(self, game: str) -> None:
1047    def run_game(self, game: str) -> None:
1048        """Run the provided game."""
1049        # pylint: disable=too-many-branches
1050        # pylint: disable=cyclic-import
1051        from bastd.ui.confirm import ConfirmWindow
1052        from bastd.ui.purchase import PurchaseWindow
1053        from bastd.ui.account import show_sign_in_prompt
1054
1055        args: dict[str, Any] = {}
1056
1057        if game == 'Easy:The Last Stand':
1058            ConfirmWindow(
1059                ba.Lstr(
1060                    resource='difficultyHardUnlockOnlyText',
1061                    fallback_resource='difficultyHardOnlyText',
1062                ),
1063                cancel_button=False,
1064                width=460,
1065                height=130,
1066            )
1067            return
1068
1069        # Infinite onslaught/runaround require pro; bring up a store link
1070        # if need be.
1071        if (
1072            game
1073            in (
1074                'Challenges:Infinite Runaround',
1075                'Challenges:Infinite Onslaught',
1076            )
1077            and not ba.app.accounts_v1.have_pro()
1078        ):
1079            if ba.internal.get_v1_account_state() != 'signed_in':
1080                show_sign_in_prompt()
1081            else:
1082                PurchaseWindow(items=['pro'])
1083            return
1084
1085        required_purchase: str | None
1086        if game in ['Challenges:Meteor Shower']:
1087            required_purchase = 'games.meteor_shower'
1088        elif game in [
1089            'Challenges:Target Practice',
1090            'Challenges:Target Practice B',
1091        ]:
1092            required_purchase = 'games.target_practice'
1093        elif game in ['Challenges:Ninja Fight']:
1094            required_purchase = 'games.ninja_fight'
1095        elif game in ['Challenges:Pro Ninja Fight']:
1096            required_purchase = 'games.ninja_fight'
1097        elif game in [
1098            'Challenges:Easter Egg Hunt',
1099            'Challenges:Pro Easter Egg Hunt',
1100        ]:
1101            required_purchase = 'games.easter_egg_hunt'
1102        else:
1103            required_purchase = None
1104
1105        if required_purchase is not None and not ba.internal.get_purchased(
1106            required_purchase
1107        ):
1108            if ba.internal.get_v1_account_state() != 'signed_in':
1109                show_sign_in_prompt()
1110            else:
1111                PurchaseWindow(items=[required_purchase])
1112            return
1113
1114        self._save_state()
1115
1116        if ba.app.launch_coop_game(game, args=args):
1117            ba.containerwidget(edit=self._root_widget, transition='out_left')

Run the provided game.

def run_tournament( self, tournament_button: bastd.ui.coop.tournamentbutton.TournamentButton) -> None:
1119    def run_tournament(self, tournament_button: TournamentButton) -> None:
1120        """Run the provided tournament game."""
1121        from bastd.ui.account import show_sign_in_prompt
1122        from bastd.ui.tournamententry import TournamentEntryWindow
1123
1124        if ba.internal.get_v1_account_state() != 'signed_in':
1125            show_sign_in_prompt()
1126            return
1127
1128        if ba.internal.workspaces_in_use():
1129            ba.screenmessage(
1130                ba.Lstr(resource='tournamentsDisabledWorkspaceText'),
1131                color=(1, 0, 0),
1132            )
1133            ba.playsound(ba.getsound('error'))
1134            return
1135
1136        if not self._tourney_data_up_to_date:
1137            ba.screenmessage(
1138                ba.Lstr(resource='tournamentCheckingStateText'), color=(1, 1, 0)
1139            )
1140            ba.playsound(ba.getsound('error'))
1141            return
1142
1143        if tournament_button.tournament_id is None:
1144            ba.screenmessage(
1145                ba.Lstr(resource='internal.unavailableNoConnectionText'),
1146                color=(1, 0, 0),
1147            )
1148            ba.playsound(ba.getsound('error'))
1149            return
1150
1151        if tournament_button.required_league is not None:
1152            ba.screenmessage(
1153                ba.Lstr(
1154                    resource='league.tournamentLeagueText',
1155                    subs=[
1156                        (
1157                            '${NAME}',
1158                            ba.Lstr(
1159                                translate=(
1160                                    'leagueNames',
1161                                    tournament_button.required_league,
1162                                )
1163                            ),
1164                        )
1165                    ],
1166                ),
1167                color=(1, 0, 0),
1168            )
1169            ba.playsound(ba.getsound('error'))
1170            return
1171
1172        if tournament_button.time_remaining <= 0:
1173            ba.screenmessage(
1174                ba.Lstr(resource='tournamentEndedText'), color=(1, 0, 0)
1175            )
1176            ba.playsound(ba.getsound('error'))
1177            return
1178
1179        self._save_state()
1180
1181        assert tournament_button.tournament_id is not None
1182        TournamentEntryWindow(
1183            tournament_id=tournament_button.tournament_id,
1184            position=tournament_button.button.get_screen_space_center(),
1185        )

Run the provided tournament game.

Inherited Members
ba.ui.Window
get_root_widget