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

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

CoopBrowserWindow( transition: str | None = 'in_right', origin_widget: _bauiv1.Widget | None = None)
 26    def __init__(
 27        self,
 28        transition: str | None = 'in_right',
 29        origin_widget: bui.Widget | None = None,
 30    ):
 31        # pylint: disable=too-many-statements
 32        # pylint: disable=cyclic-import
 33
 34        plus = bui.app.plus
 35        assert plus is not None
 36
 37        # Preload some modules we use in a background thread so we won't
 38        # have a visual hitch when the user taps them.
 39        bui.app.threadpool.submit_no_wait(self._preload_modules)
 40
 41        bui.set_analytics_screen('Coop Window')
 42
 43        app = bui.app
 44        classic = app.classic
 45        assert classic is not None
 46        cfg = app.config
 47
 48        # Quick note to players that tourneys won't work in ballistica
 49        # core builds. (need to split the word so it won't get subbed
 50        # out)
 51        if 'ballistica' + 'kit' == bui.appname() and bui.do_once():
 52            bui.apptimer(
 53                1.0,
 54                lambda: bui.screenmessage(
 55                    bui.Lstr(resource='noTournamentsInTestBuildText'),
 56                    color=(1, 1, 0),
 57                ),
 58            )
 59
 60        # Try to recreate the same number of buttons we had last time so our
 61        # re-selection code works.
 62        self._tournament_button_count = app.config.get('Tournament Rows', 0)
 63        assert isinstance(self._tournament_button_count, int)
 64
 65        self.star_tex = bui.gettexture('star')
 66        self.lsbt = bui.getmesh('level_select_button_transparent')
 67        self.lsbo = bui.getmesh('level_select_button_opaque')
 68        self.a_outline_tex = bui.gettexture('achievementOutline')
 69        self.a_outline_mesh = bui.getmesh('achievementOutline')
 70        self._campaign_sub_container: bui.Widget | None = None
 71        self._tournament_info_button: bui.Widget | None = None
 72        self._easy_button: bui.Widget | None = None
 73        self._hard_button: bui.Widget | None = None
 74        self._hard_button_lock_image: bui.Widget | None = None
 75        self._campaign_percent_text: bui.Widget | None = None
 76
 77        uiscale = app.ui_v1.uiscale
 78        self._width = 1520 if uiscale is bui.UIScale.SMALL else 1120
 79        self._x_inset = x_inset = 200 if uiscale is bui.UIScale.SMALL else 0
 80        self._height = (
 81            585
 82            if uiscale is bui.UIScale.SMALL
 83            else 730 if uiscale is bui.UIScale.MEDIUM else 800
 84        )
 85        self._r = 'coopSelectWindow'
 86        top_extra = 0 if uiscale is bui.UIScale.SMALL else 0
 87
 88        self._tourney_data_up_to_date = False
 89
 90        self._campaign_difficulty = plus.get_v1_account_misc_val(
 91            'campaignDifficulty', 'easy'
 92        )
 93
 94        if (
 95            self._campaign_difficulty == 'hard'
 96            and HARD_REQUIRES_PRO
 97            and not classic.accounts.have_pro_options()
 98        ):
 99            plus.add_v1_account_transaction(
100                {
101                    'type': 'SET_MISC_VAL',
102                    'name': 'campaignDifficulty',
103                    'value': 'easy',
104                }
105            )
106            self._campaign_difficulty = 'easy'
107
108        super().__init__(
109            root_widget=bui.containerwidget(
110                size=(self._width, self._height + top_extra),
111                toolbar_visibility='menu_full',
112                stack_offset=(
113                    (0, -8)
114                    if uiscale is bui.UIScale.SMALL
115                    else (0, 0) if uiscale is bui.UIScale.MEDIUM else (0, 0)
116                ),
117                scale=(
118                    1.31
119                    if uiscale is bui.UIScale.SMALL
120                    else 0.8 if uiscale is bui.UIScale.MEDIUM else 0.75
121                ),
122            ),
123            transition=transition,
124            origin_widget=origin_widget,
125        )
126
127        if uiscale is bui.UIScale.SMALL:
128            self._back_button = bui.get_special_widget('back_button')
129            bui.containerwidget(
130                edit=self._root_widget, on_cancel_call=self.main_window_back
131            )
132        else:
133            self._back_button = bui.buttonwidget(
134                parent=self._root_widget,
135                position=(75 + x_inset, self._height - 87),
136                size=(120, 60),
137                scale=1.2,
138                autoselect=True,
139                label=bui.Lstr(resource='backText'),
140                button_type='back',
141                on_activate_call=self.main_window_back,
142            )
143            bui.buttonwidget(
144                edit=self._back_button,
145                button_type='backSmall',
146                size=(60, 50),
147                position=(
148                    75 + x_inset,
149                    self._height - 87 + 6,
150                ),
151                label=bui.charstr(bui.SpecialChar.BACK),
152            )
153            bui.containerwidget(
154                edit=self._root_widget, cancel_button=self._back_button
155            )
156
157        self._last_tournament_query_time: float | None = None
158        self._last_tournament_query_response_time: float | None = None
159        self._doing_tournament_query = False
160
161        self._selected_campaign_level = cfg.get(
162            'Selected Coop Campaign Level', None
163        )
164        self._selected_custom_level = cfg.get(
165            'Selected Coop Custom Level', None
166        )
167
168        # Don't want initial construction affecting our last-selected.
169        self._do_selection_callbacks = False
170        v = self._height - 95
171        bui.textwidget(
172            parent=self._root_widget,
173            position=(
174                self._width * 0.5,
175                v + 40 - (25 if uiscale is bui.UIScale.SMALL else 0),
176            ),
177            size=(0, 0),
178            text=bui.Lstr(
179                resource='playModes.singlePlayerCoopText',
180                fallback_resource='playModes.coopText',
181            ),
182            h_align='center',
183            color=app.ui_v1.title_color,
184            scale=0.85 if uiscale is bui.UIScale.SMALL else 1.5,
185            maxwidth=280 if uiscale is bui.UIScale.SMALL else 500,
186            v_align='center',
187        )
188
189        self._selected_row = cfg.get('Selected Coop Row', None)
190
191        self._scroll_width = self._width - (130 + 2 * x_inset)
192        self._scroll_height = self._height - (
193            # 219 if uiscale is bui.UIScale.SMALL else 160
194            170
195            if uiscale is bui.UIScale.SMALL
196            else 160
197        )
198
199        self._subcontainerwidth = 800.0
200        self._subcontainerheight = 1400.0
201
202        self._scrollwidget = bui.scrollwidget(
203            parent=self._root_widget,
204            highlight=False,
205            position=(
206                # (65 + x_inset, 120)
207                (65 + x_inset, 70)
208                if uiscale is bui.UIScale.SMALL
209                else (65 + x_inset, 70)
210            ),
211            size=(self._scroll_width, self._scroll_height),
212            simple_culling_v=10.0,
213            claims_left_right=True,
214            selection_loops_to_parent=True,
215            border_opacity=0.4,
216        )
217
218        if uiscale is bui.UIScale.SMALL:
219            bimg = bui.imagewidget(
220                parent=self._root_widget,
221                texture=bui.gettexture('uiAtlas'),
222                mesh_transparent=bui.getmesh('windowBGBlotch'),
223                position=(x_inset + 10.0, -20),
224                size=(500.0, 200.0),
225                color=(0.4, 0.37, 0.49),
226                # color=(1, 0, 0),
227            )
228            bui.widget(edit=bimg, depth_range=(0.9, 1.0))
229            bimg = bui.imagewidget(
230                parent=self._root_widget,
231                texture=bui.gettexture('uiAtlas'),
232                mesh_transparent=bui.getmesh('windowBGBlotch'),
233                position=(x_inset + self._scroll_width - 270, -20),
234                size=(500.0, 200.0),
235                color=(0.4, 0.37, 0.49),
236                # color=(1, 0, 0),
237            )
238            bui.widget(edit=bimg, depth_range=(0.9, 1.0))
239
240        self._subcontainer: bui.Widget | None = None
241
242        # Take note of our account state; we'll refresh later if this changes.
243        self._account_state_num = plus.get_v1_account_state_num()
244
245        # Same for fg/bg state.
246        self._fg_state = app.fg_state
247
248        self._refresh()
249        self._restore_state()
250
251        # Even though we might display cached tournament data immediately, we
252        # don't consider it valid until we've pinged.
253        # the server for an update
254        self._tourney_data_up_to_date = False
255
256        # If we've got a cached tournament list for our account and info for
257        # each one of those tournaments, go ahead and display it as a
258        # starting point.
259        if (
260            classic.accounts.account_tournament_list is not None
261            and classic.accounts.account_tournament_list[0]
262            == plus.get_v1_account_state_num()
263            and all(
264                t_id in classic.accounts.tournament_info
265                for t_id in classic.accounts.account_tournament_list[1]
266            )
267        ):
268            tourney_data = [
269                classic.accounts.tournament_info[t_id]
270                for t_id in classic.accounts.account_tournament_list[1]
271            ]
272            self._update_for_data(tourney_data)
273
274        # This will pull new data periodically, update timers, etc.
275        self._update_timer = bui.AppTimer(
276            1.0, bui.WeakCall(self._update), repeat=True
277        )
278        self._update()

Create a MainWindow given a root widget and transition info.

Automatically handles in and out transitions on the provided widget, so there is no need to set transitions when creating it.

star_tex
lsbt
lsbo
a_outline_tex
a_outline_mesh
@override
def get_main_window_state(self) -> bauiv1.MainWindowState:
280    @override
281    def get_main_window_state(self) -> bui.MainWindowState:
282        # Support recreating our window for back/refresh purposes.
283        cls = type(self)
284        return bui.BasicMainWindowState(
285            create_call=lambda transition, origin_widget: cls(
286                transition=transition, origin_widget=origin_widget
287            )
288        )

Return a WindowState to recreate this window, if supported.

@override
def on_main_window_close(self) -> None:
290    @override
291    def on_main_window_close(self) -> None:
292        self._save_state()

Called before transitioning out a main window.

A good opportunity to save window state/etc.

def is_tourney_data_up_to_date(self) -> bool:
985    def is_tourney_data_up_to_date(self) -> bool:
986        """Return whether our tourney data is up to date."""
987        return self._tourney_data_up_to_date

Return whether our tourney data is up to date.

def run_game(self, game: str) -> None:
 989    def run_game(self, game: str) -> None:
 990        """Run the provided game."""
 991        # pylint: disable=too-many-branches
 992        # pylint: disable=cyclic-import
 993        from bauiv1lib.confirm import ConfirmWindow
 994        from bauiv1lib.purchase import PurchaseWindow
 995        from bauiv1lib.account.signin import show_sign_in_prompt
 996
 997        plus = bui.app.plus
 998        assert plus is not None
 999
1000        assert bui.app.classic is not None
1001
1002        args: dict[str, Any] = {}
1003
1004        if game == 'Easy:The Last Stand':
1005            ConfirmWindow(
1006                bui.Lstr(
1007                    resource='difficultyHardUnlockOnlyText',
1008                    fallback_resource='difficultyHardOnlyText',
1009                ),
1010                cancel_button=False,
1011                width=460,
1012                height=130,
1013            )
1014            return
1015
1016        required_purchase: str | None
1017
1018        # Infinite onslaught requires pro or the newer standalone
1019        # upgrade.
1020        if (
1021            game in ['Challenges:Infinite Runaround']
1022            and not bui.app.classic.accounts.have_pro()
1023        ):
1024            required_purchase = 'upgrades.infinite_runaround'
1025        elif (
1026            game in ['Challenges:Infinite Onslaught']
1027            and not bui.app.classic.accounts.have_pro()
1028        ):
1029            required_purchase = 'upgrades.infinite_onslaught'
1030        elif game in ['Challenges:Meteor Shower']:
1031            required_purchase = 'games.meteor_shower'
1032        elif game in [
1033            'Challenges:Target Practice',
1034            'Challenges:Target Practice B',
1035        ]:
1036            required_purchase = 'games.target_practice'
1037        elif game in ['Challenges:Ninja Fight']:
1038            required_purchase = 'games.ninja_fight'
1039        elif game in ['Challenges:Pro Ninja Fight']:
1040            required_purchase = 'games.ninja_fight'
1041        elif game in [
1042            'Challenges:Easter Egg Hunt',
1043            'Challenges:Pro Easter Egg Hunt',
1044        ]:
1045            required_purchase = 'games.easter_egg_hunt'
1046        else:
1047            required_purchase = None
1048
1049        if (
1050            required_purchase is not None
1051            and not plus.get_v1_account_product_purchased(required_purchase)
1052        ):
1053            if plus.get_v1_account_state() != 'signed_in':
1054                show_sign_in_prompt()
1055            else:
1056                PurchaseWindow(items=[required_purchase])
1057            return
1058
1059        self._save_state()
1060
1061        if bui.app.classic.launch_coop_game(game, args=args):
1062            bui.containerwidget(edit=self._root_widget, transition='out_left')

Run the provided game.

def run_tournament( self, tournament_button: bauiv1lib.coop.tournamentbutton.TournamentButton) -> None:
1064    def run_tournament(self, tournament_button: TournamentButton) -> None:
1065        """Run the provided tournament game."""
1066        from bauiv1lib.account.signin import show_sign_in_prompt
1067        from bauiv1lib.tournamententry import TournamentEntryWindow
1068
1069        plus = bui.app.plus
1070        assert plus is not None
1071
1072        if plus.get_v1_account_state() != 'signed_in':
1073            show_sign_in_prompt()
1074            return
1075
1076        if bui.workspaces_in_use():
1077            bui.screenmessage(
1078                bui.Lstr(resource='tournamentsDisabledWorkspaceText'),
1079                color=(1, 0, 0),
1080            )
1081            bui.getsound('error').play()
1082            return
1083
1084        if not self._tourney_data_up_to_date:
1085            bui.screenmessage(
1086                bui.Lstr(resource='tournamentCheckingStateText'),
1087                color=(1, 1, 0),
1088            )
1089            bui.getsound('error').play()
1090            return
1091
1092        if tournament_button.tournament_id is None:
1093            bui.screenmessage(
1094                bui.Lstr(resource='internal.unavailableNoConnectionText'),
1095                color=(1, 0, 0),
1096            )
1097            bui.getsound('error').play()
1098            return
1099
1100        if tournament_button.required_league is not None:
1101            bui.screenmessage(
1102                bui.Lstr(
1103                    resource='league.tournamentLeagueText',
1104                    subs=[
1105                        (
1106                            '${NAME}',
1107                            bui.Lstr(
1108                                translate=(
1109                                    'leagueNames',
1110                                    tournament_button.required_league,
1111                                )
1112                            ),
1113                        )
1114                    ],
1115                ),
1116                color=(1, 0, 0),
1117            )
1118            bui.getsound('error').play()
1119            return
1120
1121        if tournament_button.time_remaining <= 0:
1122            bui.screenmessage(
1123                bui.Lstr(resource='tournamentEndedText'), color=(1, 0, 0)
1124            )
1125            bui.getsound('error').play()
1126            return
1127
1128        self._save_state()
1129
1130        assert tournament_button.tournament_id is not None
1131        TournamentEntryWindow(
1132            tournament_id=tournament_button.tournament_id,
1133            position=tournament_button.button.get_screen_space_center(),
1134        )

Run the provided tournament game.