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

Return a WindowState to recreate this window, if supported.

@override
def on_main_window_close(self) -> None:
304    @override
305    def on_main_window_close(self) -> None:
306        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:
1000    def is_tourney_data_up_to_date(self) -> bool:
1001        """Return whether our tourney data is up to date."""
1002        return self._tourney_data_up_to_date

Return whether our tourney data is up to date.

def run_game(self, game: str, origin_widget: _bauiv1.Widget | None = None) -> None:
1004    def run_game(
1005        self, game: str, origin_widget: bui.Widget | None = None
1006    ) -> None:
1007        """Run the provided game."""
1008        from efro.util import strict_partial
1009        from bauiv1lib.confirm import ConfirmWindow
1010
1011        classic = bui.app.classic
1012        assert classic is not None
1013
1014        if classic.chest_dock_full:
1015            ConfirmWindow(
1016                bui.Lstr(resource='chests.slotsFullWarningText'),
1017                width=550,
1018                height=140,
1019                ok_text=bui.Lstr(resource='continueText'),
1020                origin_widget=origin_widget,
1021                action=strict_partial(
1022                    self._run_game, game=game, origin_widget=origin_widget
1023                ),
1024            )
1025        else:
1026            self._run_game(game=game, origin_widget=origin_widget)

Run the provided game.

def run_tournament( self, tournament_button: bauiv1lib.coop.tournamentbutton.TournamentButton) -> None:
1074    def run_tournament(self, tournament_button: TournamentButton) -> None:
1075        """Run the provided tournament game."""
1076        # pylint: disable=too-many-return-statements
1077
1078        from bauiv1lib.purchase import PurchaseWindow
1079        from bauiv1lib.account.signin import show_sign_in_prompt
1080        from bauiv1lib.tournamententry import TournamentEntryWindow
1081
1082        plus = bui.app.plus
1083        assert plus is not None
1084
1085        classic = bui.app.classic
1086        assert classic is not None
1087
1088        if plus.get_v1_account_state() != 'signed_in':
1089            show_sign_in_prompt()
1090            return
1091
1092        if bui.workspaces_in_use():
1093            bui.screenmessage(
1094                bui.Lstr(resource='tournamentsDisabledWorkspaceText'),
1095                color=(1, 0, 0),
1096            )
1097            bui.getsound('error').play()
1098            return
1099
1100        if not self._tourney_data_up_to_date:
1101            bui.screenmessage(
1102                bui.Lstr(resource='tournamentCheckingStateText'),
1103                color=(1, 1, 0),
1104            )
1105            bui.getsound('error').play()
1106            return
1107
1108        if tournament_button.tournament_id is None:
1109            bui.screenmessage(
1110                bui.Lstr(resource='internal.unavailableNoConnectionText'),
1111                color=(1, 0, 0),
1112            )
1113            bui.getsound('error').play()
1114            return
1115
1116        if tournament_button.required_league is not None:
1117            bui.screenmessage(
1118                bui.Lstr(
1119                    resource='league.tournamentLeagueText',
1120                    subs=[
1121                        (
1122                            '${NAME}',
1123                            bui.Lstr(
1124                                translate=(
1125                                    'leagueNames',
1126                                    tournament_button.required_league,
1127                                )
1128                            ),
1129                        )
1130                    ],
1131                ),
1132                color=(1, 0, 0),
1133            )
1134            bui.getsound('error').play()
1135            return
1136
1137        if tournament_button.game is not None and not classic.is_game_unlocked(
1138            tournament_button.game
1139        ):
1140            required_purchases = classic.required_purchases_for_game(
1141                tournament_button.game
1142            )
1143            # We gotta be missing *something* if its locked.
1144            assert required_purchases
1145
1146            for purchase in required_purchases:
1147                if not plus.get_v1_account_product_purchased(purchase):
1148                    if plus.get_v1_account_state() != 'signed_in':
1149                        show_sign_in_prompt()
1150                    else:
1151                        PurchaseWindow(
1152                            items=[purchase],
1153                            origin_widget=tournament_button.button,
1154                        )
1155                    return
1156
1157            # assert required_purchases
1158            # if plus.get_v1_account_state() != 'signed_in':
1159            #     show_sign_in_prompt()
1160            # else:
1161            #     # Hmm; just show the first requirement. They can come
1162            #     # back to see more after they purchase the first.
1163            #     PurchaseWindow(
1164            #         items=[required_purchases[0]],
1165            #         origin_widget=tournament_button.button,
1166            #     )
1167            # return
1168
1169        if tournament_button.time_remaining <= 0:
1170            bui.screenmessage(
1171                bui.Lstr(resource='tournamentEndedText'), color=(1, 0, 0)
1172            )
1173            bui.getsound('error').play()
1174            return
1175
1176        self._save_state()
1177
1178        assert tournament_button.tournament_id is not None
1179        TournamentEntryWindow(
1180            tournament_id=tournament_button.tournament_id,
1181            position=tournament_button.button.get_screen_space_center(),
1182        )

Run the provided tournament game.