bastd.ui.store.browser

UI for browsing the store.

   1# Released under the MIT License. See LICENSE for details.
   2#
   3"""UI for browsing the store."""
   4# pylint: disable=too-many-lines
   5from __future__ import annotations
   6
   7import time
   8import copy
   9import math
  10import logging
  11import weakref
  12from enum import Enum
  13from threading import Thread
  14from typing import TYPE_CHECKING
  15
  16from efro.error import CommunicationError
  17import bacommon.cloud
  18import ba
  19import ba.internal
  20
  21if TYPE_CHECKING:
  22    from typing import Any, Callable, Sequence
  23
  24MERCH_LINK_KEY = 'Merch Link'
  25
  26
  27class StoreBrowserWindow(ba.Window):
  28    """Window for browsing the store."""
  29
  30    class TabID(Enum):
  31        """Our available tab types."""
  32
  33        EXTRAS = 'extras'
  34        MAPS = 'maps'
  35        MINIGAMES = 'minigames'
  36        CHARACTERS = 'characters'
  37        ICONS = 'icons'
  38
  39    def __init__(
  40        self,
  41        transition: str = 'in_right',
  42        modal: bool = False,
  43        show_tab: StoreBrowserWindow.TabID | None = None,
  44        on_close_call: Callable[[], Any] | None = None,
  45        back_location: str | None = None,
  46        origin_widget: ba.Widget | None = None,
  47    ):
  48        # pylint: disable=too-many-statements
  49        # pylint: disable=too-many-locals
  50        from bastd.ui.tabs import TabRow
  51        from ba import SpecialChar
  52
  53        app = ba.app
  54        uiscale = app.ui.uiscale
  55
  56        ba.set_analytics_screen('Store Window')
  57
  58        scale_origin: tuple[float, float] | None
  59
  60        # If they provided an origin-widget, scale up from that.
  61        if origin_widget is not None:
  62            self._transition_out = 'out_scale'
  63            scale_origin = origin_widget.get_screen_space_center()
  64            transition = 'in_scale'
  65        else:
  66            self._transition_out = 'out_right'
  67            scale_origin = None
  68
  69        self.button_infos: dict[str, dict[str, Any]] | None = None
  70        self.update_buttons_timer: ba.Timer | None = None
  71        self._status_textwidget_update_timer = None
  72
  73        self._back_location = back_location
  74        self._on_close_call = on_close_call
  75        self._show_tab = show_tab
  76        self._modal = modal
  77        self._width = 1240 if uiscale is ba.UIScale.SMALL else 1040
  78        self._x_inset = x_inset = 100 if uiscale is ba.UIScale.SMALL else 0
  79        self._height = (
  80            578
  81            if uiscale is ba.UIScale.SMALL
  82            else 645
  83            if uiscale is ba.UIScale.MEDIUM
  84            else 800
  85        )
  86        self._current_tab: StoreBrowserWindow.TabID | None = None
  87        extra_top = 30 if uiscale is ba.UIScale.SMALL else 0
  88
  89        self._request: Any = None
  90        self._r = 'store'
  91        self._last_buy_time: float | None = None
  92
  93        super().__init__(
  94            root_widget=ba.containerwidget(
  95                size=(self._width, self._height + extra_top),
  96                transition=transition,
  97                toolbar_visibility='menu_full',
  98                scale=(
  99                    1.3
 100                    if uiscale is ba.UIScale.SMALL
 101                    else 0.9
 102                    if uiscale is ba.UIScale.MEDIUM
 103                    else 0.8
 104                ),
 105                scale_origin_stack_offset=scale_origin,
 106                stack_offset=(
 107                    (0, -5)
 108                    if uiscale is ba.UIScale.SMALL
 109                    else (0, 0)
 110                    if uiscale is ba.UIScale.MEDIUM
 111                    else (0, 0)
 112                ),
 113            )
 114        )
 115
 116        self._back_button = btn = ba.buttonwidget(
 117            parent=self._root_widget,
 118            position=(70 + x_inset, self._height - 74),
 119            size=(140, 60),
 120            scale=1.1,
 121            autoselect=True,
 122            label=ba.Lstr(resource='doneText' if self._modal else 'backText'),
 123            button_type=None if self._modal else 'back',
 124            on_activate_call=self._back,
 125        )
 126        ba.containerwidget(edit=self._root_widget, cancel_button=btn)
 127
 128        self._ticket_count_text: ba.Widget | None = None
 129        self._get_tickets_button: ba.Widget | None = None
 130
 131        if ba.app.allow_ticket_purchases:
 132            self._get_tickets_button = ba.buttonwidget(
 133                parent=self._root_widget,
 134                size=(210, 65),
 135                on_activate_call=self._on_get_more_tickets_press,
 136                autoselect=True,
 137                scale=0.9,
 138                text_scale=1.4,
 139                left_widget=self._back_button,
 140                color=(0.7, 0.5, 0.85),
 141                textcolor=(0.2, 1.0, 0.2),
 142                label=ba.Lstr(resource='getTicketsWindow.titleText'),
 143            )
 144        else:
 145            self._ticket_count_text = ba.textwidget(
 146                parent=self._root_widget,
 147                size=(210, 64),
 148                color=(0.2, 1.0, 0.2),
 149                h_align='center',
 150                v_align='center',
 151            )
 152
 153        # Move this dynamically to keep it out of the way of the party icon.
 154        self._update_get_tickets_button_pos()
 155        self._get_ticket_pos_update_timer = ba.Timer(
 156            1.0,
 157            ba.WeakCall(self._update_get_tickets_button_pos),
 158            repeat=True,
 159            timetype=ba.TimeType.REAL,
 160        )
 161        if self._get_tickets_button:
 162            ba.widget(
 163                edit=self._back_button, right_widget=self._get_tickets_button
 164            )
 165        self._ticket_text_update_timer = ba.Timer(
 166            1.0,
 167            ba.WeakCall(self._update_tickets_text),
 168            timetype=ba.TimeType.REAL,
 169            repeat=True,
 170        )
 171        self._update_tickets_text()
 172
 173        app = ba.app
 174        if app.platform in ['mac', 'ios'] and app.subplatform == 'appstore':
 175            ba.buttonwidget(
 176                parent=self._root_widget,
 177                position=(self._width * 0.5 - 70, 16),
 178                size=(230, 50),
 179                scale=0.65,
 180                on_activate_call=ba.WeakCall(self._restore_purchases),
 181                color=(0.35, 0.3, 0.4),
 182                selectable=False,
 183                textcolor=(0.55, 0.5, 0.6),
 184                label=ba.Lstr(resource='getTicketsWindow.restorePurchasesText'),
 185            )
 186
 187        ba.textwidget(
 188            parent=self._root_widget,
 189            position=(self._width * 0.5, self._height - 44),
 190            size=(0, 0),
 191            color=app.ui.title_color,
 192            scale=1.5,
 193            h_align='center',
 194            v_align='center',
 195            text=ba.Lstr(resource='storeText'),
 196            maxwidth=420,
 197        )
 198
 199        if not self._modal:
 200            ba.buttonwidget(
 201                edit=self._back_button,
 202                button_type='backSmall',
 203                size=(60, 60),
 204                label=ba.charstr(SpecialChar.BACK),
 205            )
 206
 207        scroll_buffer_h = 130 + 2 * x_inset
 208        tab_buffer_h = 250 + 2 * x_inset
 209
 210        tabs_def = [
 211            (self.TabID.EXTRAS, ba.Lstr(resource=self._r + '.extrasText')),
 212            (self.TabID.MAPS, ba.Lstr(resource=self._r + '.mapsText')),
 213            (
 214                self.TabID.MINIGAMES,
 215                ba.Lstr(resource=self._r + '.miniGamesText'),
 216            ),
 217            (
 218                self.TabID.CHARACTERS,
 219                ba.Lstr(resource=self._r + '.charactersText'),
 220            ),
 221            (self.TabID.ICONS, ba.Lstr(resource=self._r + '.iconsText')),
 222        ]
 223
 224        self._tab_row = TabRow(
 225            self._root_widget,
 226            tabs_def,
 227            pos=(tab_buffer_h * 0.5, self._height - 130),
 228            size=(self._width - tab_buffer_h, 50),
 229            on_select_call=self._set_tab,
 230        )
 231
 232        self._purchasable_count_widgets: dict[
 233            StoreBrowserWindow.TabID, dict[str, Any]
 234        ] = {}
 235
 236        # Create our purchasable-items tags and have them update over time.
 237        for tab_id, tab in self._tab_row.tabs.items():
 238            pos = tab.position
 239            size = tab.size
 240            button = tab.button
 241            rad = 10
 242            center = (pos[0] + 0.1 * size[0], pos[1] + 0.9 * size[1])
 243            img = ba.imagewidget(
 244                parent=self._root_widget,
 245                position=(center[0] - rad * 1.04, center[1] - rad * 1.15),
 246                size=(rad * 2.2, rad * 2.2),
 247                texture=ba.gettexture('circleShadow'),
 248                color=(1, 0, 0),
 249            )
 250            txt = ba.textwidget(
 251                parent=self._root_widget,
 252                position=center,
 253                size=(0, 0),
 254                h_align='center',
 255                v_align='center',
 256                maxwidth=1.4 * rad,
 257                scale=0.6,
 258                shadow=1.0,
 259                flatness=1.0,
 260            )
 261            rad = 20
 262            sale_img = ba.imagewidget(
 263                parent=self._root_widget,
 264                position=(center[0] - rad, center[1] - rad),
 265                size=(rad * 2, rad * 2),
 266                draw_controller=button,
 267                texture=ba.gettexture('circleZigZag'),
 268                color=(0.5, 0, 1.0),
 269            )
 270            sale_title_text = ba.textwidget(
 271                parent=self._root_widget,
 272                position=(center[0], center[1] + 0.24 * rad),
 273                size=(0, 0),
 274                h_align='center',
 275                v_align='center',
 276                draw_controller=button,
 277                maxwidth=1.4 * rad,
 278                scale=0.6,
 279                shadow=0.0,
 280                flatness=1.0,
 281                color=(0, 1, 0),
 282            )
 283            sale_time_text = ba.textwidget(
 284                parent=self._root_widget,
 285                position=(center[0], center[1] - 0.29 * rad),
 286                size=(0, 0),
 287                h_align='center',
 288                v_align='center',
 289                draw_controller=button,
 290                maxwidth=1.4 * rad,
 291                scale=0.4,
 292                shadow=0.0,
 293                flatness=1.0,
 294                color=(0, 1, 0),
 295            )
 296            self._purchasable_count_widgets[tab_id] = {
 297                'img': img,
 298                'text': txt,
 299                'sale_img': sale_img,
 300                'sale_title_text': sale_title_text,
 301                'sale_time_text': sale_time_text,
 302            }
 303        self._tab_update_timer = ba.Timer(
 304            1.0,
 305            ba.WeakCall(self._update_tabs),
 306            timetype=ba.TimeType.REAL,
 307            repeat=True,
 308        )
 309        self._update_tabs()
 310
 311        if self._get_tickets_button:
 312            last_tab_button = self._tab_row.tabs[tabs_def[-1][0]].button
 313            ba.widget(
 314                edit=self._get_tickets_button, down_widget=last_tab_button
 315            )
 316            ba.widget(
 317                edit=last_tab_button,
 318                up_widget=self._get_tickets_button,
 319                right_widget=self._get_tickets_button,
 320            )
 321
 322        self._scroll_width = self._width - scroll_buffer_h
 323        self._scroll_height = self._height - 180
 324
 325        self._scrollwidget: ba.Widget | None = None
 326        self._status_textwidget: ba.Widget | None = None
 327        self._restore_state()
 328
 329    def _update_get_tickets_button_pos(self) -> None:
 330        uiscale = ba.app.ui.uiscale
 331        pos = (
 332            self._width
 333            - 252
 334            - (
 335                self._x_inset
 336                + (
 337                    47
 338                    if uiscale is ba.UIScale.SMALL
 339                    and ba.internal.is_party_icon_visible()
 340                    else 0
 341                )
 342            ),
 343            self._height - 70,
 344        )
 345        if self._get_tickets_button:
 346            ba.buttonwidget(edit=self._get_tickets_button, position=pos)
 347        if self._ticket_count_text:
 348            ba.textwidget(edit=self._ticket_count_text, position=pos)
 349
 350    def _restore_purchases(self) -> None:
 351        from bastd.ui import account
 352
 353        if ba.internal.get_v1_account_state() != 'signed_in':
 354            account.show_sign_in_prompt()
 355        else:
 356            ba.internal.restore_purchases()
 357
 358    def _update_tabs(self) -> None:
 359        from ba.internal import (
 360            get_available_sale_time,
 361            get_available_purchase_count,
 362        )
 363
 364        if not self._root_widget:
 365            return
 366        for tab_id, tab_data in list(self._purchasable_count_widgets.items()):
 367            sale_time = get_available_sale_time(tab_id.value)
 368
 369            if sale_time is not None:
 370                ba.textwidget(
 371                    edit=tab_data['sale_title_text'],
 372                    text=ba.Lstr(resource='store.saleText'),
 373                )
 374                ba.textwidget(
 375                    edit=tab_data['sale_time_text'],
 376                    text=ba.timestring(
 377                        sale_time,
 378                        centi=False,
 379                        timeformat=ba.TimeFormat.MILLISECONDS,
 380                    ),
 381                )
 382                ba.imagewidget(edit=tab_data['sale_img'], opacity=1.0)
 383                count = 0
 384            else:
 385                ba.textwidget(edit=tab_data['sale_title_text'], text='')
 386                ba.textwidget(edit=tab_data['sale_time_text'], text='')
 387                ba.imagewidget(edit=tab_data['sale_img'], opacity=0.0)
 388                count = get_available_purchase_count(tab_id.value)
 389
 390            if count > 0:
 391                ba.textwidget(edit=tab_data['text'], text=str(count))
 392                ba.imagewidget(edit=tab_data['img'], opacity=1.0)
 393            else:
 394                ba.textwidget(edit=tab_data['text'], text='')
 395                ba.imagewidget(edit=tab_data['img'], opacity=0.0)
 396
 397    def _update_tickets_text(self) -> None:
 398        from ba import SpecialChar
 399
 400        if not self._root_widget:
 401            return
 402        sval: str | ba.Lstr
 403        if ba.internal.get_v1_account_state() == 'signed_in':
 404            sval = ba.charstr(SpecialChar.TICKET) + str(
 405                ba.internal.get_v1_account_ticket_count()
 406            )
 407        else:
 408            sval = ba.Lstr(resource='getTicketsWindow.titleText')
 409        if self._get_tickets_button:
 410            ba.buttonwidget(edit=self._get_tickets_button, label=sval)
 411        if self._ticket_count_text:
 412            ba.textwidget(edit=self._ticket_count_text, text=sval)
 413
 414    def _set_tab(self, tab_id: TabID) -> None:
 415        if self._current_tab is tab_id:
 416            return
 417        self._current_tab = tab_id
 418
 419        # We wanna preserve our current tab between runs.
 420        cfg = ba.app.config
 421        cfg['Store Tab'] = tab_id.value
 422        cfg.commit()
 423
 424        # Update tab colors based on which is selected.
 425        self._tab_row.update_appearance(tab_id)
 426
 427        # (Re)create scroll widget.
 428        if self._scrollwidget:
 429            self._scrollwidget.delete()
 430
 431        self._scrollwidget = ba.scrollwidget(
 432            parent=self._root_widget,
 433            highlight=False,
 434            position=(
 435                (self._width - self._scroll_width) * 0.5,
 436                self._height - self._scroll_height - 79 - 48,
 437            ),
 438            size=(self._scroll_width, self._scroll_height),
 439            claims_left_right=True,
 440            claims_tab=True,
 441            selection_loops_to_parent=True,
 442        )
 443
 444        # NOTE: this stuff is modified by the _Store class.
 445        # Should maybe clean that up.
 446        self.button_infos = {}
 447        self.update_buttons_timer = None
 448
 449        # Show status over top.
 450        if self._status_textwidget:
 451            self._status_textwidget.delete()
 452        self._status_textwidget = ba.textwidget(
 453            parent=self._root_widget,
 454            position=(self._width * 0.5, self._height * 0.5),
 455            size=(0, 0),
 456            color=(1, 0.7, 1, 0.5),
 457            h_align='center',
 458            v_align='center',
 459            text=ba.Lstr(resource=self._r + '.loadingText'),
 460            maxwidth=self._scroll_width * 0.9,
 461        )
 462
 463        class _Request:
 464            def __init__(self, window: StoreBrowserWindow):
 465                self._window = weakref.ref(window)
 466                data = {'tab': tab_id.value}
 467                ba.timer(
 468                    0.1,
 469                    ba.WeakCall(self._on_response, data),
 470                    timetype=ba.TimeType.REAL,
 471                )
 472
 473            def _on_response(self, data: dict[str, Any] | None) -> None:
 474                # FIXME: clean this up.
 475                # pylint: disable=protected-access
 476                window = self._window()
 477                if window is not None and (window._request is self):
 478                    window._request = None
 479                    # noinspection PyProtectedMember
 480                    window._on_response(data)
 481
 482        # Kick off a server request.
 483        self._request = _Request(self)
 484
 485    # Actually start the purchase locally.
 486    def _purchase_check_result(
 487        self, item: str, is_ticket_purchase: bool, result: dict[str, Any] | None
 488    ) -> None:
 489        if result is None:
 490            ba.playsound(ba.getsound('error'))
 491            ba.screenmessage(
 492                ba.Lstr(resource='internal.unavailableNoConnectionText'),
 493                color=(1, 0, 0),
 494            )
 495        else:
 496            if is_ticket_purchase:
 497                if result['allow']:
 498                    price = ba.internal.get_v1_account_misc_read_val(
 499                        'price.' + item, None
 500                    )
 501                    if (
 502                        price is None
 503                        or not isinstance(price, int)
 504                        or price <= 0
 505                    ):
 506                        print(
 507                            'Error; got invalid local price of',
 508                            price,
 509                            'for item',
 510                            item,
 511                        )
 512                        ba.playsound(ba.getsound('error'))
 513                    else:
 514                        ba.playsound(ba.getsound('click01'))
 515                        ba.internal.in_game_purchase(item, price)
 516                else:
 517                    if result['reason'] == 'versionTooOld':
 518                        ba.playsound(ba.getsound('error'))
 519                        ba.screenmessage(
 520                            ba.Lstr(
 521                                resource='getTicketsWindow.versionTooOldText'
 522                            ),
 523                            color=(1, 0, 0),
 524                        )
 525                    else:
 526                        ba.playsound(ba.getsound('error'))
 527                        ba.screenmessage(
 528                            ba.Lstr(
 529                                resource='getTicketsWindow.unavailableText'
 530                            ),
 531                            color=(1, 0, 0),
 532                        )
 533            # Real in-app purchase.
 534            else:
 535                if result['allow']:
 536                    ba.internal.purchase(item)
 537                else:
 538                    if result['reason'] == 'versionTooOld':
 539                        ba.playsound(ba.getsound('error'))
 540                        ba.screenmessage(
 541                            ba.Lstr(
 542                                resource='getTicketsWindow.versionTooOldText'
 543                            ),
 544                            color=(1, 0, 0),
 545                        )
 546                    else:
 547                        ba.playsound(ba.getsound('error'))
 548                        ba.screenmessage(
 549                            ba.Lstr(
 550                                resource='getTicketsWindow.unavailableText'
 551                            ),
 552                            color=(1, 0, 0),
 553                        )
 554
 555    def _do_purchase_check(
 556        self, item: str, is_ticket_purchase: bool = False
 557    ) -> None:
 558        from ba.internal import master_server_get
 559
 560        # Here we ping the server to ask if it's valid for us to
 561        # purchase this. Better to fail now than after we've
 562        # paid locally.
 563        app = ba.app
 564        master_server_get(
 565            'bsAccountPurchaseCheck',
 566            {
 567                'item': item,
 568                'platform': app.platform,
 569                'subplatform': app.subplatform,
 570                'version': app.version,
 571                'buildNumber': app.build_number,
 572                'purchaseType': 'ticket' if is_ticket_purchase else 'real',
 573            },
 574            callback=ba.WeakCall(
 575                self._purchase_check_result, item, is_ticket_purchase
 576            ),
 577        )
 578
 579    def buy(self, item: str) -> None:
 580        """Attempt to purchase the provided item."""
 581        from ba.internal import (
 582            get_available_sale_time,
 583            get_store_item_name_translated,
 584        )
 585        from bastd.ui import account
 586        from bastd.ui.confirm import ConfirmWindow
 587        from bastd.ui import getcurrency
 588
 589        # Prevent pressing buy within a few seconds of the last press
 590        # (gives the buttons time to disable themselves and whatnot).
 591        curtime = ba.time(ba.TimeType.REAL)
 592        if (
 593            self._last_buy_time is not None
 594            and (curtime - self._last_buy_time) < 2.0
 595        ):
 596            ba.playsound(ba.getsound('error'))
 597        else:
 598            if ba.internal.get_v1_account_state() != 'signed_in':
 599                account.show_sign_in_prompt()
 600            else:
 601                self._last_buy_time = curtime
 602
 603                # Merch is a special case - just a link.
 604                if item == 'merch':
 605                    url = ba.app.config.get('Merch Link')
 606                    if isinstance(url, str):
 607                        ba.open_url(url)
 608
 609                # Pro is an actual IAP, and the rest are ticket purchases.
 610                elif item == 'pro':
 611                    ba.playsound(ba.getsound('click01'))
 612
 613                    # Purchase either pro or pro_sale depending on whether
 614                    # there is a sale going on.
 615                    self._do_purchase_check(
 616                        'pro'
 617                        if get_available_sale_time('extras') is None
 618                        else 'pro_sale'
 619                    )
 620                else:
 621                    price = ba.internal.get_v1_account_misc_read_val(
 622                        'price.' + item, None
 623                    )
 624                    our_tickets = ba.internal.get_v1_account_ticket_count()
 625                    if price is not None and our_tickets < price:
 626                        ba.playsound(ba.getsound('error'))
 627                        getcurrency.show_get_tickets_prompt()
 628                    else:
 629
 630                        def do_it() -> None:
 631                            self._do_purchase_check(
 632                                item, is_ticket_purchase=True
 633                            )
 634
 635                        ba.playsound(ba.getsound('swish'))
 636                        ConfirmWindow(
 637                            ba.Lstr(
 638                                resource='store.purchaseConfirmText',
 639                                subs=[
 640                                    (
 641                                        '${ITEM}',
 642                                        get_store_item_name_translated(item),
 643                                    )
 644                                ],
 645                            ),
 646                            width=400,
 647                            height=120,
 648                            action=do_it,
 649                            ok_text=ba.Lstr(
 650                                resource='store.purchaseText',
 651                                fallback_resource='okText',
 652                            ),
 653                        )
 654
 655    def _print_already_own(self, charname: str) -> None:
 656        ba.screenmessage(
 657            ba.Lstr(
 658                resource=self._r + '.alreadyOwnText',
 659                subs=[('${NAME}', charname)],
 660            ),
 661            color=(1, 0, 0),
 662        )
 663        ba.playsound(ba.getsound('error'))
 664
 665    def update_buttons(self) -> None:
 666        """Update our buttons."""
 667        # pylint: disable=too-many-statements
 668        # pylint: disable=too-many-branches
 669        # pylint: disable=too-many-locals
 670        from ba.internal import get_available_sale_time
 671        from ba import SpecialChar
 672
 673        if not self._root_widget:
 674            return
 675        import datetime
 676
 677        sales_raw = ba.internal.get_v1_account_misc_read_val('sales', {})
 678        sales = {}
 679        try:
 680            # Look at the current set of sales; filter any with time remaining.
 681            for sale_item, sale_info in list(sales_raw.items()):
 682                to_end = (
 683                    datetime.datetime.utcfromtimestamp(sale_info['e'])
 684                    - datetime.datetime.utcnow()
 685                ).total_seconds()
 686                if to_end > 0:
 687                    sales[sale_item] = {
 688                        'to_end': to_end,
 689                        'original_price': sale_info['op'],
 690                    }
 691        except Exception:
 692            ba.print_exception('Error parsing sales.')
 693
 694        assert self.button_infos is not None
 695        for b_type, b_info in self.button_infos.items():
 696
 697            if b_type == 'merch':
 698                purchased = False
 699            elif b_type in ['upgrades.pro', 'pro']:
 700                purchased = ba.app.accounts_v1.have_pro()
 701            else:
 702                purchased = ba.internal.get_purchased(b_type)
 703
 704            sale_opacity = 0.0
 705            sale_title_text: str | ba.Lstr = ''
 706            sale_time_text: str | ba.Lstr = ''
 707
 708            if purchased:
 709                title_color = (0.8, 0.7, 0.9, 1.0)
 710                color = (0.63, 0.55, 0.78)
 711                extra_image_opacity = 0.5
 712                call = ba.WeakCall(self._print_already_own, b_info['name'])
 713                price_text = ''
 714                price_text_left = ''
 715                price_text_right = ''
 716                show_purchase_check = True
 717                description_color: Sequence[float] = (0.4, 1.0, 0.4, 0.4)
 718                description_color2: Sequence[float] = (0.0, 0.0, 0.0, 0.0)
 719                price_color = (0.5, 1, 0.5, 0.3)
 720            else:
 721                title_color = (0.7, 0.9, 0.7, 1.0)
 722                color = (0.4, 0.8, 0.1)
 723                extra_image_opacity = 1.0
 724                call = b_info['call'] if 'call' in b_info else None
 725                if b_type == 'merch':
 726                    price_text = ''
 727                    price_text_left = ''
 728                    price_text_right = ''
 729                elif b_type in ['upgrades.pro', 'pro']:
 730                    sale_time = get_available_sale_time('extras')
 731                    if sale_time is not None:
 732                        priceraw = ba.internal.get_price('pro')
 733                        price_text_left = (
 734                            priceraw if priceraw is not None else '?'
 735                        )
 736                        priceraw = ba.internal.get_price('pro_sale')
 737                        price_text_right = (
 738                            priceraw if priceraw is not None else '?'
 739                        )
 740                        sale_opacity = 1.0
 741                        price_text = ''
 742                        sale_title_text = ba.Lstr(resource='store.saleText')
 743                        sale_time_text = ba.timestring(
 744                            sale_time,
 745                            centi=False,
 746                            timeformat=ba.TimeFormat.MILLISECONDS,
 747                        )
 748                    else:
 749                        priceraw = ba.internal.get_price('pro')
 750                        price_text = priceraw if priceraw is not None else '?'
 751                        price_text_left = ''
 752                        price_text_right = ''
 753                else:
 754                    price = ba.internal.get_v1_account_misc_read_val(
 755                        'price.' + b_type, 0
 756                    )
 757
 758                    # Color the button differently if we cant afford this.
 759                    if ba.internal.get_v1_account_state() == 'signed_in':
 760                        if ba.internal.get_v1_account_ticket_count() < price:
 761                            color = (0.6, 0.61, 0.6)
 762                    price_text = ba.charstr(ba.SpecialChar.TICKET) + str(
 763                        ba.internal.get_v1_account_misc_read_val(
 764                            'price.' + b_type, '?'
 765                        )
 766                    )
 767                    price_text_left = ''
 768                    price_text_right = ''
 769
 770                    # TESTING:
 771                    if b_type in sales:
 772                        sale_opacity = 1.0
 773                        price_text_left = ba.charstr(SpecialChar.TICKET) + str(
 774                            sales[b_type]['original_price']
 775                        )
 776                        price_text_right = price_text
 777                        price_text = ''
 778                        sale_title_text = ba.Lstr(resource='store.saleText')
 779                        sale_time_text = ba.timestring(
 780                            int(sales[b_type]['to_end'] * 1000),
 781                            centi=False,
 782                            timeformat=ba.TimeFormat.MILLISECONDS,
 783                        )
 784
 785                description_color = (0.5, 1.0, 0.5)
 786                description_color2 = (0.3, 1.0, 1.0)
 787                price_color = (0.2, 1, 0.2, 1.0)
 788                show_purchase_check = False
 789
 790            if 'title_text' in b_info:
 791                ba.textwidget(edit=b_info['title_text'], color=title_color)
 792            if 'purchase_check' in b_info:
 793                ba.imagewidget(
 794                    edit=b_info['purchase_check'],
 795                    opacity=1.0 if show_purchase_check else 0.0,
 796                )
 797            if 'price_widget' in b_info:
 798                ba.textwidget(
 799                    edit=b_info['price_widget'],
 800                    text=price_text,
 801                    color=price_color,
 802                )
 803            if 'price_widget_left' in b_info:
 804                ba.textwidget(
 805                    edit=b_info['price_widget_left'], text=price_text_left
 806                )
 807            if 'price_widget_right' in b_info:
 808                ba.textwidget(
 809                    edit=b_info['price_widget_right'], text=price_text_right
 810                )
 811            if 'price_slash_widget' in b_info:
 812                ba.imagewidget(
 813                    edit=b_info['price_slash_widget'], opacity=sale_opacity
 814                )
 815            if 'sale_bg_widget' in b_info:
 816                ba.imagewidget(
 817                    edit=b_info['sale_bg_widget'], opacity=sale_opacity
 818                )
 819            if 'sale_title_widget' in b_info:
 820                ba.textwidget(
 821                    edit=b_info['sale_title_widget'], text=sale_title_text
 822                )
 823            if 'sale_time_widget' in b_info:
 824                ba.textwidget(
 825                    edit=b_info['sale_time_widget'], text=sale_time_text
 826                )
 827            if 'button' in b_info:
 828                ba.buttonwidget(
 829                    edit=b_info['button'], color=color, on_activate_call=call
 830                )
 831            if 'extra_backings' in b_info:
 832                for bck in b_info['extra_backings']:
 833                    ba.imagewidget(
 834                        edit=bck, color=color, opacity=extra_image_opacity
 835                    )
 836            if 'extra_images' in b_info:
 837                for img in b_info['extra_images']:
 838                    ba.imagewidget(edit=img, opacity=extra_image_opacity)
 839            if 'extra_texts' in b_info:
 840                for etxt in b_info['extra_texts']:
 841                    ba.textwidget(edit=etxt, color=description_color)
 842            if 'extra_texts_2' in b_info:
 843                for etxt in b_info['extra_texts_2']:
 844                    ba.textwidget(edit=etxt, color=description_color2)
 845            if 'descriptionText' in b_info:
 846                ba.textwidget(
 847                    edit=b_info['descriptionText'], color=description_color
 848                )
 849
 850    def _on_response(self, data: dict[str, Any] | None) -> None:
 851        # pylint: disable=too-many-statements
 852
 853        # clear status text..
 854        if self._status_textwidget:
 855            self._status_textwidget.delete()
 856            self._status_textwidget_update_timer = None
 857
 858        if data is None:
 859            self._status_textwidget = ba.textwidget(
 860                parent=self._root_widget,
 861                position=(self._width * 0.5, self._height * 0.5),
 862                size=(0, 0),
 863                scale=1.3,
 864                transition_delay=0.1,
 865                color=(1, 0.3, 0.3, 1.0),
 866                h_align='center',
 867                v_align='center',
 868                text=ba.Lstr(resource=self._r + '.loadErrorText'),
 869                maxwidth=self._scroll_width * 0.9,
 870            )
 871        else:
 872
 873            class _Store:
 874                def __init__(
 875                    self,
 876                    store_window: StoreBrowserWindow,
 877                    sdata: dict[str, Any],
 878                    width: float,
 879                ):
 880                    from ba.internal import (
 881                        get_store_item_display_size,
 882                        get_store_layout,
 883                    )
 884
 885                    self._store_window = store_window
 886                    self._width = width
 887                    store_data = get_store_layout()
 888                    self._tab = sdata['tab']
 889                    self._sections = copy.deepcopy(store_data[sdata['tab']])
 890                    self._height: float | None = None
 891
 892                    uiscale = ba.app.ui.uiscale
 893
 894                    # Pre-calc a few things and add them to store-data.
 895                    for section in self._sections:
 896                        if self._tab == 'characters':
 897                            dummy_name = 'characters.foo'
 898                        elif self._tab == 'extras':
 899                            dummy_name = 'pro'
 900                        elif self._tab == 'maps':
 901                            dummy_name = 'maps.foo'
 902                        elif self._tab == 'icons':
 903                            dummy_name = 'icons.foo'
 904                        else:
 905                            dummy_name = ''
 906                        section['button_size'] = get_store_item_display_size(
 907                            dummy_name
 908                        )
 909                        section['v_spacing'] = (
 910                            -25
 911                            if (
 912                                self._tab == 'extras'
 913                                and uiscale is ba.UIScale.SMALL
 914                            )
 915                            else -17
 916                            if self._tab == 'characters'
 917                            else 0
 918                        )
 919                        if 'title' not in section:
 920                            section['title'] = ''
 921                        section['x_offs'] = (
 922                            130
 923                            if self._tab == 'extras'
 924                            else 270
 925                            if self._tab == 'maps'
 926                            else 0
 927                        )
 928                        section['y_offs'] = (
 929                            20
 930                            if (
 931                                self._tab == 'extras'
 932                                and uiscale is ba.UIScale.SMALL
 933                                and ba.app.config.get('Merch Link')
 934                            )
 935                            else 55
 936                            if (
 937                                self._tab == 'extras'
 938                                and uiscale is ba.UIScale.SMALL
 939                            )
 940                            else -20
 941                            if self._tab == 'icons'
 942                            else 0
 943                        )
 944
 945                def instantiate(
 946                    self, scrollwidget: ba.Widget, tab_button: ba.Widget
 947                ) -> None:
 948                    """Create the store."""
 949                    # pylint: disable=too-many-locals
 950                    # pylint: disable=too-many-branches
 951                    # pylint: disable=too-many-nested-blocks
 952                    from bastd.ui.store.item import (
 953                        instantiate_store_item_display,
 954                    )
 955
 956                    title_spacing = 40
 957                    button_border = 20
 958                    button_spacing = 4
 959                    boffs_h = 40
 960                    self._height = 80.0
 961
 962                    # Calc total height.
 963                    for i, section in enumerate(self._sections):
 964                        if section['title'] != '':
 965                            assert self._height is not None
 966                            self._height += title_spacing
 967                        b_width, b_height = section['button_size']
 968                        b_column_count = int(
 969                            math.floor(
 970                                (self._width - boffs_h - 20)
 971                                / (b_width + button_spacing)
 972                            )
 973                        )
 974                        b_row_count = int(
 975                            math.ceil(
 976                                float(len(section['items'])) / b_column_count
 977                            )
 978                        )
 979                        b_height_total = (
 980                            2 * button_border
 981                            + b_row_count * b_height
 982                            + (b_row_count - 1) * section['v_spacing']
 983                        )
 984                        self._height += b_height_total
 985
 986                    assert self._height is not None
 987                    cnt2 = ba.containerwidget(
 988                        parent=scrollwidget,
 989                        scale=1.0,
 990                        size=(self._width, self._height),
 991                        background=False,
 992                        claims_left_right=True,
 993                        claims_tab=True,
 994                        selection_loops_to_parent=True,
 995                    )
 996                    v = self._height - 20
 997
 998                    if self._tab == 'characters':
 999                        txt = ba.Lstr(
1000                            resource='store.howToSwitchCharactersText',
1001                            subs=[
1002                                (
1003                                    '${SETTINGS}',
1004                                    ba.Lstr(
1005                                        resource=(
1006                                            'accountSettingsWindow.titleText'
1007                                        )
1008                                    ),
1009                                ),
1010                                (
1011                                    '${PLAYER_PROFILES}',
1012                                    ba.Lstr(
1013                                        resource=(
1014                                            'playerProfilesWindow.titleText'
1015                                        )
1016                                    ),
1017                                ),
1018                            ],
1019                        )
1020                        ba.textwidget(
1021                            parent=cnt2,
1022                            text=txt,
1023                            size=(0, 0),
1024                            position=(self._width * 0.5, self._height - 28),
1025                            h_align='center',
1026                            v_align='center',
1027                            color=(0.7, 1, 0.7, 0.4),
1028                            scale=0.7,
1029                            shadow=0,
1030                            flatness=1.0,
1031                            maxwidth=700,
1032                            transition_delay=0.4,
1033                        )
1034                    elif self._tab == 'icons':
1035                        txt = ba.Lstr(
1036                            resource='store.howToUseIconsText',
1037                            subs=[
1038                                (
1039                                    '${SETTINGS}',
1040                                    ba.Lstr(resource='mainMenu.settingsText'),
1041                                ),
1042                                (
1043                                    '${PLAYER_PROFILES}',
1044                                    ba.Lstr(
1045                                        resource=(
1046                                            'playerProfilesWindow.titleText'
1047                                        )
1048                                    ),
1049                                ),
1050                            ],
1051                        )
1052                        ba.textwidget(
1053                            parent=cnt2,
1054                            text=txt,
1055                            size=(0, 0),
1056                            position=(self._width * 0.5, self._height - 28),
1057                            h_align='center',
1058                            v_align='center',
1059                            color=(0.7, 1, 0.7, 0.4),
1060                            scale=0.7,
1061                            shadow=0,
1062                            flatness=1.0,
1063                            maxwidth=700,
1064                            transition_delay=0.4,
1065                        )
1066                    elif self._tab == 'maps':
1067                        assert self._width is not None
1068                        assert self._height is not None
1069                        txt = ba.Lstr(resource='store.howToUseMapsText')
1070                        ba.textwidget(
1071                            parent=cnt2,
1072                            text=txt,
1073                            size=(0, 0),
1074                            position=(self._width * 0.5, self._height - 28),
1075                            h_align='center',
1076                            v_align='center',
1077                            color=(0.7, 1, 0.7, 0.4),
1078                            scale=0.7,
1079                            shadow=0,
1080                            flatness=1.0,
1081                            maxwidth=700,
1082                            transition_delay=0.4,
1083                        )
1084
1085                    prev_row_buttons: list | None = None
1086                    this_row_buttons = []
1087
1088                    delay = 0.3
1089                    for section in self._sections:
1090                        if section['title'] != '':
1091                            ba.textwidget(
1092                                parent=cnt2,
1093                                position=(60, v - title_spacing * 0.8),
1094                                size=(0, 0),
1095                                scale=1.0,
1096                                transition_delay=delay,
1097                                color=(0.7, 0.9, 0.7, 1),
1098                                h_align='left',
1099                                v_align='center',
1100                                text=ba.Lstr(resource=section['title']),
1101                                maxwidth=self._width * 0.7,
1102                            )
1103                            v -= title_spacing
1104                        delay = max(0.100, delay - 0.100)
1105                        v -= button_border
1106                        b_width, b_height = section['button_size']
1107                        b_count = len(section['items'])
1108                        b_column_count = int(
1109                            math.floor(
1110                                (self._width - boffs_h - 20)
1111                                / (b_width + button_spacing)
1112                            )
1113                        )
1114                        col = 0
1115                        item: dict[str, Any]
1116                        assert self._store_window.button_infos is not None
1117                        for i, item_name in enumerate(section['items']):
1118                            item = self._store_window.button_infos[
1119                                item_name
1120                            ] = {}
1121                            item['call'] = ba.WeakCall(
1122                                self._store_window.buy, item_name
1123                            )
1124                            if 'x_offs' in section:
1125                                boffs_h2 = section['x_offs']
1126                            else:
1127                                boffs_h2 = 0
1128
1129                            if 'y_offs' in section:
1130                                boffs_v2 = section['y_offs']
1131                            else:
1132                                boffs_v2 = 0
1133                            b_pos = (
1134                                boffs_h
1135                                + boffs_h2
1136                                + (b_width + button_spacing) * col,
1137                                v - b_height + boffs_v2,
1138                            )
1139                            instantiate_store_item_display(
1140                                item_name,
1141                                item,
1142                                parent_widget=cnt2,
1143                                b_pos=b_pos,
1144                                boffs_h=boffs_h,
1145                                b_width=b_width,
1146                                b_height=b_height,
1147                                boffs_h2=boffs_h2,
1148                                boffs_v2=boffs_v2,
1149                                delay=delay,
1150                            )
1151                            btn = item['button']
1152                            delay = max(0.1, delay - 0.1)
1153                            this_row_buttons.append(btn)
1154
1155                            # Wire this button to the equivalent in the
1156                            # previous row.
1157                            if prev_row_buttons is not None:
1158                                if len(prev_row_buttons) > col:
1159                                    ba.widget(
1160                                        edit=btn,
1161                                        up_widget=prev_row_buttons[col],
1162                                    )
1163                                    ba.widget(
1164                                        edit=prev_row_buttons[col],
1165                                        down_widget=btn,
1166                                    )
1167
1168                                    # If we're the last button in our row,
1169                                    # wire any in the previous row past
1170                                    # our position to go to us if down is
1171                                    # pressed.
1172                                    if (
1173                                        col + 1 == b_column_count
1174                                        or i == b_count - 1
1175                                    ):
1176                                        for b_prev in prev_row_buttons[
1177                                            col + 1 :
1178                                        ]:
1179                                            ba.widget(
1180                                                edit=b_prev, down_widget=btn
1181                                            )
1182                                else:
1183                                    ba.widget(
1184                                        edit=btn, up_widget=prev_row_buttons[-1]
1185                                    )
1186                            else:
1187                                ba.widget(edit=btn, up_widget=tab_button)
1188
1189                            col += 1
1190                            if col == b_column_count or i == b_count - 1:
1191                                prev_row_buttons = this_row_buttons
1192                                this_row_buttons = []
1193                                col = 0
1194                                v -= b_height
1195                                if i < b_count - 1:
1196                                    v -= section['v_spacing']
1197
1198                        v -= button_border
1199
1200                    # Set a timer to update these buttons periodically as long
1201                    # as we're alive (so if we buy one it will grey out, etc).
1202                    self._store_window.update_buttons_timer = ba.Timer(
1203                        0.5,
1204                        ba.WeakCall(self._store_window.update_buttons),
1205                        repeat=True,
1206                        timetype=ba.TimeType.REAL,
1207                    )
1208
1209                    # Also update them immediately.
1210                    self._store_window.update_buttons()
1211
1212            if self._current_tab in (
1213                self.TabID.EXTRAS,
1214                self.TabID.MINIGAMES,
1215                self.TabID.CHARACTERS,
1216                self.TabID.MAPS,
1217                self.TabID.ICONS,
1218            ):
1219                store = _Store(self, data, self._scroll_width)
1220                assert self._scrollwidget is not None
1221                store.instantiate(
1222                    scrollwidget=self._scrollwidget,
1223                    tab_button=self._tab_row.tabs[self._current_tab].button,
1224                )
1225            else:
1226                cnt = ba.containerwidget(
1227                    parent=self._scrollwidget,
1228                    scale=1.0,
1229                    size=(self._scroll_width, self._scroll_height * 0.95),
1230                    background=False,
1231                    claims_left_right=True,
1232                    claims_tab=True,
1233                    selection_loops_to_parent=True,
1234                )
1235                self._status_textwidget = ba.textwidget(
1236                    parent=cnt,
1237                    position=(
1238                        self._scroll_width * 0.5,
1239                        self._scroll_height * 0.5,
1240                    ),
1241                    size=(0, 0),
1242                    scale=1.3,
1243                    transition_delay=0.1,
1244                    color=(1, 1, 0.3, 1.0),
1245                    h_align='center',
1246                    v_align='center',
1247                    text=ba.Lstr(resource=self._r + '.comingSoonText'),
1248                    maxwidth=self._scroll_width * 0.9,
1249                )
1250
1251    def _save_state(self) -> None:
1252        try:
1253            sel = self._root_widget.get_selected_child()
1254            selected_tab_ids = [
1255                tab_id
1256                for tab_id, tab in self._tab_row.tabs.items()
1257                if sel == tab.button
1258            ]
1259            if sel == self._get_tickets_button:
1260                sel_name = 'GetTickets'
1261            elif sel == self._scrollwidget:
1262                sel_name = 'Scroll'
1263            elif sel == self._back_button:
1264                sel_name = 'Back'
1265            elif selected_tab_ids:
1266                assert len(selected_tab_ids) == 1
1267                sel_name = f'Tab:{selected_tab_ids[0].value}'
1268            else:
1269                raise ValueError(f'unrecognized selection \'{sel}\'')
1270            ba.app.ui.window_states[type(self)] = {
1271                'sel_name': sel_name,
1272            }
1273        except Exception:
1274            ba.print_exception(f'Error saving state for {self}.')
1275
1276    def _restore_state(self) -> None:
1277        from efro.util import enum_by_value
1278
1279        try:
1280            sel: ba.Widget | None
1281            sel_name = ba.app.ui.window_states.get(type(self), {}).get(
1282                'sel_name'
1283            )
1284            assert isinstance(sel_name, (str, type(None)))
1285
1286            try:
1287                current_tab = enum_by_value(
1288                    self.TabID, ba.app.config.get('Store Tab')
1289                )
1290            except ValueError:
1291                current_tab = self.TabID.CHARACTERS
1292
1293            if self._show_tab is not None:
1294                current_tab = self._show_tab
1295            if sel_name == 'GetTickets' and self._get_tickets_button:
1296                sel = self._get_tickets_button
1297            elif sel_name == 'Back':
1298                sel = self._back_button
1299            elif sel_name == 'Scroll':
1300                sel = self._scrollwidget
1301            elif isinstance(sel_name, str) and sel_name.startswith('Tab:'):
1302                try:
1303                    sel_tab_id = enum_by_value(
1304                        self.TabID, sel_name.split(':')[-1]
1305                    )
1306                except ValueError:
1307                    sel_tab_id = self.TabID.CHARACTERS
1308                sel = self._tab_row.tabs[sel_tab_id].button
1309            else:
1310                sel = self._tab_row.tabs[current_tab].button
1311
1312            # If we were requested to show a tab, select it too..
1313            if (
1314                self._show_tab is not None
1315                and self._show_tab in self._tab_row.tabs
1316            ):
1317                sel = self._tab_row.tabs[self._show_tab].button
1318            self._set_tab(current_tab)
1319            if sel is not None:
1320                ba.containerwidget(edit=self._root_widget, selected_child=sel)
1321        except Exception:
1322            ba.print_exception(f'Error restoring state for {self}.')
1323
1324    def _on_get_more_tickets_press(self) -> None:
1325        # pylint: disable=cyclic-import
1326        from bastd.ui.account import show_sign_in_prompt
1327        from bastd.ui.getcurrency import GetCurrencyWindow
1328
1329        if ba.internal.get_v1_account_state() != 'signed_in':
1330            show_sign_in_prompt()
1331            return
1332        self._save_state()
1333        ba.containerwidget(edit=self._root_widget, transition='out_left')
1334        window = GetCurrencyWindow(
1335            from_modal_store=self._modal,
1336            store_back_location=self._back_location,
1337        ).get_root_widget()
1338        if not self._modal:
1339            ba.app.ui.set_main_menu_window(window)
1340
1341    def _back(self) -> None:
1342        # pylint: disable=cyclic-import
1343        from bastd.ui.coop.browser import CoopBrowserWindow
1344        from bastd.ui.mainmenu import MainMenuWindow
1345
1346        self._save_state()
1347        ba.containerwidget(
1348            edit=self._root_widget, transition=self._transition_out
1349        )
1350        if not self._modal:
1351            if self._back_location == 'CoopBrowserWindow':
1352                ba.app.ui.set_main_menu_window(
1353                    CoopBrowserWindow(transition='in_left').get_root_widget()
1354                )
1355            else:
1356                ba.app.ui.set_main_menu_window(
1357                    MainMenuWindow(transition='in_left').get_root_widget()
1358                )
1359        if self._on_close_call is not None:
1360            self._on_close_call()
1361
1362
1363def _check_merch_availability_in_bg_thread() -> None:
1364    # pylint: disable=cell-var-from-loop
1365
1366    # Merch is available from some countries only.
1367    # Make a reasonable check to ask the master-server about this at
1368    # launch and store the results.
1369    for _i in range(15):
1370        try:
1371            if ba.app.cloud.is_connected():
1372                response = ba.app.cloud.send_message(
1373                    bacommon.cloud.MerchAvailabilityMessage()
1374                )
1375
1376                def _store_in_logic_thread() -> None:
1377                    cfg = ba.app.config
1378                    current: str | None = cfg.get(MERCH_LINK_KEY)
1379                    if not isinstance(current, str | None):
1380                        current = None
1381                    if current != response.url:
1382                        cfg[MERCH_LINK_KEY] = response.url
1383                        cfg.commit()
1384
1385                # If we successfully get a response, kick it over to the
1386                # logic thread to store and we're done.
1387                ba.pushcall(_store_in_logic_thread, from_other_thread=True)
1388                return
1389        except CommunicationError:
1390            pass
1391        except Exception:
1392            logging.warning(
1393                'Unexpected error in merch-availability-check.', exc_info=True
1394            )
1395        time.sleep(1.1934)  # A bit randomized to avoid aliasing.
1396
1397
1398Thread(target=_check_merch_availability_in_bg_thread, daemon=True).start()
class StoreBrowserWindow(ba.ui.Window):
  28class StoreBrowserWindow(ba.Window):
  29    """Window for browsing the store."""
  30
  31    class TabID(Enum):
  32        """Our available tab types."""
  33
  34        EXTRAS = 'extras'
  35        MAPS = 'maps'
  36        MINIGAMES = 'minigames'
  37        CHARACTERS = 'characters'
  38        ICONS = 'icons'
  39
  40    def __init__(
  41        self,
  42        transition: str = 'in_right',
  43        modal: bool = False,
  44        show_tab: StoreBrowserWindow.TabID | None = None,
  45        on_close_call: Callable[[], Any] | None = None,
  46        back_location: str | None = None,
  47        origin_widget: ba.Widget | None = None,
  48    ):
  49        # pylint: disable=too-many-statements
  50        # pylint: disable=too-many-locals
  51        from bastd.ui.tabs import TabRow
  52        from ba import SpecialChar
  53
  54        app = ba.app
  55        uiscale = app.ui.uiscale
  56
  57        ba.set_analytics_screen('Store Window')
  58
  59        scale_origin: tuple[float, float] | None
  60
  61        # If they provided an origin-widget, scale up from that.
  62        if origin_widget is not None:
  63            self._transition_out = 'out_scale'
  64            scale_origin = origin_widget.get_screen_space_center()
  65            transition = 'in_scale'
  66        else:
  67            self._transition_out = 'out_right'
  68            scale_origin = None
  69
  70        self.button_infos: dict[str, dict[str, Any]] | None = None
  71        self.update_buttons_timer: ba.Timer | None = None
  72        self._status_textwidget_update_timer = None
  73
  74        self._back_location = back_location
  75        self._on_close_call = on_close_call
  76        self._show_tab = show_tab
  77        self._modal = modal
  78        self._width = 1240 if uiscale is ba.UIScale.SMALL else 1040
  79        self._x_inset = x_inset = 100 if uiscale is ba.UIScale.SMALL else 0
  80        self._height = (
  81            578
  82            if uiscale is ba.UIScale.SMALL
  83            else 645
  84            if uiscale is ba.UIScale.MEDIUM
  85            else 800
  86        )
  87        self._current_tab: StoreBrowserWindow.TabID | None = None
  88        extra_top = 30 if uiscale is ba.UIScale.SMALL else 0
  89
  90        self._request: Any = None
  91        self._r = 'store'
  92        self._last_buy_time: float | None = None
  93
  94        super().__init__(
  95            root_widget=ba.containerwidget(
  96                size=(self._width, self._height + extra_top),
  97                transition=transition,
  98                toolbar_visibility='menu_full',
  99                scale=(
 100                    1.3
 101                    if uiscale is ba.UIScale.SMALL
 102                    else 0.9
 103                    if uiscale is ba.UIScale.MEDIUM
 104                    else 0.8
 105                ),
 106                scale_origin_stack_offset=scale_origin,
 107                stack_offset=(
 108                    (0, -5)
 109                    if uiscale is ba.UIScale.SMALL
 110                    else (0, 0)
 111                    if uiscale is ba.UIScale.MEDIUM
 112                    else (0, 0)
 113                ),
 114            )
 115        )
 116
 117        self._back_button = btn = ba.buttonwidget(
 118            parent=self._root_widget,
 119            position=(70 + x_inset, self._height - 74),
 120            size=(140, 60),
 121            scale=1.1,
 122            autoselect=True,
 123            label=ba.Lstr(resource='doneText' if self._modal else 'backText'),
 124            button_type=None if self._modal else 'back',
 125            on_activate_call=self._back,
 126        )
 127        ba.containerwidget(edit=self._root_widget, cancel_button=btn)
 128
 129        self._ticket_count_text: ba.Widget | None = None
 130        self._get_tickets_button: ba.Widget | None = None
 131
 132        if ba.app.allow_ticket_purchases:
 133            self._get_tickets_button = ba.buttonwidget(
 134                parent=self._root_widget,
 135                size=(210, 65),
 136                on_activate_call=self._on_get_more_tickets_press,
 137                autoselect=True,
 138                scale=0.9,
 139                text_scale=1.4,
 140                left_widget=self._back_button,
 141                color=(0.7, 0.5, 0.85),
 142                textcolor=(0.2, 1.0, 0.2),
 143                label=ba.Lstr(resource='getTicketsWindow.titleText'),
 144            )
 145        else:
 146            self._ticket_count_text = ba.textwidget(
 147                parent=self._root_widget,
 148                size=(210, 64),
 149                color=(0.2, 1.0, 0.2),
 150                h_align='center',
 151                v_align='center',
 152            )
 153
 154        # Move this dynamically to keep it out of the way of the party icon.
 155        self._update_get_tickets_button_pos()
 156        self._get_ticket_pos_update_timer = ba.Timer(
 157            1.0,
 158            ba.WeakCall(self._update_get_tickets_button_pos),
 159            repeat=True,
 160            timetype=ba.TimeType.REAL,
 161        )
 162        if self._get_tickets_button:
 163            ba.widget(
 164                edit=self._back_button, right_widget=self._get_tickets_button
 165            )
 166        self._ticket_text_update_timer = ba.Timer(
 167            1.0,
 168            ba.WeakCall(self._update_tickets_text),
 169            timetype=ba.TimeType.REAL,
 170            repeat=True,
 171        )
 172        self._update_tickets_text()
 173
 174        app = ba.app
 175        if app.platform in ['mac', 'ios'] and app.subplatform == 'appstore':
 176            ba.buttonwidget(
 177                parent=self._root_widget,
 178                position=(self._width * 0.5 - 70, 16),
 179                size=(230, 50),
 180                scale=0.65,
 181                on_activate_call=ba.WeakCall(self._restore_purchases),
 182                color=(0.35, 0.3, 0.4),
 183                selectable=False,
 184                textcolor=(0.55, 0.5, 0.6),
 185                label=ba.Lstr(resource='getTicketsWindow.restorePurchasesText'),
 186            )
 187
 188        ba.textwidget(
 189            parent=self._root_widget,
 190            position=(self._width * 0.5, self._height - 44),
 191            size=(0, 0),
 192            color=app.ui.title_color,
 193            scale=1.5,
 194            h_align='center',
 195            v_align='center',
 196            text=ba.Lstr(resource='storeText'),
 197            maxwidth=420,
 198        )
 199
 200        if not self._modal:
 201            ba.buttonwidget(
 202                edit=self._back_button,
 203                button_type='backSmall',
 204                size=(60, 60),
 205                label=ba.charstr(SpecialChar.BACK),
 206            )
 207
 208        scroll_buffer_h = 130 + 2 * x_inset
 209        tab_buffer_h = 250 + 2 * x_inset
 210
 211        tabs_def = [
 212            (self.TabID.EXTRAS, ba.Lstr(resource=self._r + '.extrasText')),
 213            (self.TabID.MAPS, ba.Lstr(resource=self._r + '.mapsText')),
 214            (
 215                self.TabID.MINIGAMES,
 216                ba.Lstr(resource=self._r + '.miniGamesText'),
 217            ),
 218            (
 219                self.TabID.CHARACTERS,
 220                ba.Lstr(resource=self._r + '.charactersText'),
 221            ),
 222            (self.TabID.ICONS, ba.Lstr(resource=self._r + '.iconsText')),
 223        ]
 224
 225        self._tab_row = TabRow(
 226            self._root_widget,
 227            tabs_def,
 228            pos=(tab_buffer_h * 0.5, self._height - 130),
 229            size=(self._width - tab_buffer_h, 50),
 230            on_select_call=self._set_tab,
 231        )
 232
 233        self._purchasable_count_widgets: dict[
 234            StoreBrowserWindow.TabID, dict[str, Any]
 235        ] = {}
 236
 237        # Create our purchasable-items tags and have them update over time.
 238        for tab_id, tab in self._tab_row.tabs.items():
 239            pos = tab.position
 240            size = tab.size
 241            button = tab.button
 242            rad = 10
 243            center = (pos[0] + 0.1 * size[0], pos[1] + 0.9 * size[1])
 244            img = ba.imagewidget(
 245                parent=self._root_widget,
 246                position=(center[0] - rad * 1.04, center[1] - rad * 1.15),
 247                size=(rad * 2.2, rad * 2.2),
 248                texture=ba.gettexture('circleShadow'),
 249                color=(1, 0, 0),
 250            )
 251            txt = ba.textwidget(
 252                parent=self._root_widget,
 253                position=center,
 254                size=(0, 0),
 255                h_align='center',
 256                v_align='center',
 257                maxwidth=1.4 * rad,
 258                scale=0.6,
 259                shadow=1.0,
 260                flatness=1.0,
 261            )
 262            rad = 20
 263            sale_img = ba.imagewidget(
 264                parent=self._root_widget,
 265                position=(center[0] - rad, center[1] - rad),
 266                size=(rad * 2, rad * 2),
 267                draw_controller=button,
 268                texture=ba.gettexture('circleZigZag'),
 269                color=(0.5, 0, 1.0),
 270            )
 271            sale_title_text = ba.textwidget(
 272                parent=self._root_widget,
 273                position=(center[0], center[1] + 0.24 * rad),
 274                size=(0, 0),
 275                h_align='center',
 276                v_align='center',
 277                draw_controller=button,
 278                maxwidth=1.4 * rad,
 279                scale=0.6,
 280                shadow=0.0,
 281                flatness=1.0,
 282                color=(0, 1, 0),
 283            )
 284            sale_time_text = ba.textwidget(
 285                parent=self._root_widget,
 286                position=(center[0], center[1] - 0.29 * rad),
 287                size=(0, 0),
 288                h_align='center',
 289                v_align='center',
 290                draw_controller=button,
 291                maxwidth=1.4 * rad,
 292                scale=0.4,
 293                shadow=0.0,
 294                flatness=1.0,
 295                color=(0, 1, 0),
 296            )
 297            self._purchasable_count_widgets[tab_id] = {
 298                'img': img,
 299                'text': txt,
 300                'sale_img': sale_img,
 301                'sale_title_text': sale_title_text,
 302                'sale_time_text': sale_time_text,
 303            }
 304        self._tab_update_timer = ba.Timer(
 305            1.0,
 306            ba.WeakCall(self._update_tabs),
 307            timetype=ba.TimeType.REAL,
 308            repeat=True,
 309        )
 310        self._update_tabs()
 311
 312        if self._get_tickets_button:
 313            last_tab_button = self._tab_row.tabs[tabs_def[-1][0]].button
 314            ba.widget(
 315                edit=self._get_tickets_button, down_widget=last_tab_button
 316            )
 317            ba.widget(
 318                edit=last_tab_button,
 319                up_widget=self._get_tickets_button,
 320                right_widget=self._get_tickets_button,
 321            )
 322
 323        self._scroll_width = self._width - scroll_buffer_h
 324        self._scroll_height = self._height - 180
 325
 326        self._scrollwidget: ba.Widget | None = None
 327        self._status_textwidget: ba.Widget | None = None
 328        self._restore_state()
 329
 330    def _update_get_tickets_button_pos(self) -> None:
 331        uiscale = ba.app.ui.uiscale
 332        pos = (
 333            self._width
 334            - 252
 335            - (
 336                self._x_inset
 337                + (
 338                    47
 339                    if uiscale is ba.UIScale.SMALL
 340                    and ba.internal.is_party_icon_visible()
 341                    else 0
 342                )
 343            ),
 344            self._height - 70,
 345        )
 346        if self._get_tickets_button:
 347            ba.buttonwidget(edit=self._get_tickets_button, position=pos)
 348        if self._ticket_count_text:
 349            ba.textwidget(edit=self._ticket_count_text, position=pos)
 350
 351    def _restore_purchases(self) -> None:
 352        from bastd.ui import account
 353
 354        if ba.internal.get_v1_account_state() != 'signed_in':
 355            account.show_sign_in_prompt()
 356        else:
 357            ba.internal.restore_purchases()
 358
 359    def _update_tabs(self) -> None:
 360        from ba.internal import (
 361            get_available_sale_time,
 362            get_available_purchase_count,
 363        )
 364
 365        if not self._root_widget:
 366            return
 367        for tab_id, tab_data in list(self._purchasable_count_widgets.items()):
 368            sale_time = get_available_sale_time(tab_id.value)
 369
 370            if sale_time is not None:
 371                ba.textwidget(
 372                    edit=tab_data['sale_title_text'],
 373                    text=ba.Lstr(resource='store.saleText'),
 374                )
 375                ba.textwidget(
 376                    edit=tab_data['sale_time_text'],
 377                    text=ba.timestring(
 378                        sale_time,
 379                        centi=False,
 380                        timeformat=ba.TimeFormat.MILLISECONDS,
 381                    ),
 382                )
 383                ba.imagewidget(edit=tab_data['sale_img'], opacity=1.0)
 384                count = 0
 385            else:
 386                ba.textwidget(edit=tab_data['sale_title_text'], text='')
 387                ba.textwidget(edit=tab_data['sale_time_text'], text='')
 388                ba.imagewidget(edit=tab_data['sale_img'], opacity=0.0)
 389                count = get_available_purchase_count(tab_id.value)
 390
 391            if count > 0:
 392                ba.textwidget(edit=tab_data['text'], text=str(count))
 393                ba.imagewidget(edit=tab_data['img'], opacity=1.0)
 394            else:
 395                ba.textwidget(edit=tab_data['text'], text='')
 396                ba.imagewidget(edit=tab_data['img'], opacity=0.0)
 397
 398    def _update_tickets_text(self) -> None:
 399        from ba import SpecialChar
 400
 401        if not self._root_widget:
 402            return
 403        sval: str | ba.Lstr
 404        if ba.internal.get_v1_account_state() == 'signed_in':
 405            sval = ba.charstr(SpecialChar.TICKET) + str(
 406                ba.internal.get_v1_account_ticket_count()
 407            )
 408        else:
 409            sval = ba.Lstr(resource='getTicketsWindow.titleText')
 410        if self._get_tickets_button:
 411            ba.buttonwidget(edit=self._get_tickets_button, label=sval)
 412        if self._ticket_count_text:
 413            ba.textwidget(edit=self._ticket_count_text, text=sval)
 414
 415    def _set_tab(self, tab_id: TabID) -> None:
 416        if self._current_tab is tab_id:
 417            return
 418        self._current_tab = tab_id
 419
 420        # We wanna preserve our current tab between runs.
 421        cfg = ba.app.config
 422        cfg['Store Tab'] = tab_id.value
 423        cfg.commit()
 424
 425        # Update tab colors based on which is selected.
 426        self._tab_row.update_appearance(tab_id)
 427
 428        # (Re)create scroll widget.
 429        if self._scrollwidget:
 430            self._scrollwidget.delete()
 431
 432        self._scrollwidget = ba.scrollwidget(
 433            parent=self._root_widget,
 434            highlight=False,
 435            position=(
 436                (self._width - self._scroll_width) * 0.5,
 437                self._height - self._scroll_height - 79 - 48,
 438            ),
 439            size=(self._scroll_width, self._scroll_height),
 440            claims_left_right=True,
 441            claims_tab=True,
 442            selection_loops_to_parent=True,
 443        )
 444
 445        # NOTE: this stuff is modified by the _Store class.
 446        # Should maybe clean that up.
 447        self.button_infos = {}
 448        self.update_buttons_timer = None
 449
 450        # Show status over top.
 451        if self._status_textwidget:
 452            self._status_textwidget.delete()
 453        self._status_textwidget = ba.textwidget(
 454            parent=self._root_widget,
 455            position=(self._width * 0.5, self._height * 0.5),
 456            size=(0, 0),
 457            color=(1, 0.7, 1, 0.5),
 458            h_align='center',
 459            v_align='center',
 460            text=ba.Lstr(resource=self._r + '.loadingText'),
 461            maxwidth=self._scroll_width * 0.9,
 462        )
 463
 464        class _Request:
 465            def __init__(self, window: StoreBrowserWindow):
 466                self._window = weakref.ref(window)
 467                data = {'tab': tab_id.value}
 468                ba.timer(
 469                    0.1,
 470                    ba.WeakCall(self._on_response, data),
 471                    timetype=ba.TimeType.REAL,
 472                )
 473
 474            def _on_response(self, data: dict[str, Any] | None) -> None:
 475                # FIXME: clean this up.
 476                # pylint: disable=protected-access
 477                window = self._window()
 478                if window is not None and (window._request is self):
 479                    window._request = None
 480                    # noinspection PyProtectedMember
 481                    window._on_response(data)
 482
 483        # Kick off a server request.
 484        self._request = _Request(self)
 485
 486    # Actually start the purchase locally.
 487    def _purchase_check_result(
 488        self, item: str, is_ticket_purchase: bool, result: dict[str, Any] | None
 489    ) -> None:
 490        if result is None:
 491            ba.playsound(ba.getsound('error'))
 492            ba.screenmessage(
 493                ba.Lstr(resource='internal.unavailableNoConnectionText'),
 494                color=(1, 0, 0),
 495            )
 496        else:
 497            if is_ticket_purchase:
 498                if result['allow']:
 499                    price = ba.internal.get_v1_account_misc_read_val(
 500                        'price.' + item, None
 501                    )
 502                    if (
 503                        price is None
 504                        or not isinstance(price, int)
 505                        or price <= 0
 506                    ):
 507                        print(
 508                            'Error; got invalid local price of',
 509                            price,
 510                            'for item',
 511                            item,
 512                        )
 513                        ba.playsound(ba.getsound('error'))
 514                    else:
 515                        ba.playsound(ba.getsound('click01'))
 516                        ba.internal.in_game_purchase(item, price)
 517                else:
 518                    if result['reason'] == 'versionTooOld':
 519                        ba.playsound(ba.getsound('error'))
 520                        ba.screenmessage(
 521                            ba.Lstr(
 522                                resource='getTicketsWindow.versionTooOldText'
 523                            ),
 524                            color=(1, 0, 0),
 525                        )
 526                    else:
 527                        ba.playsound(ba.getsound('error'))
 528                        ba.screenmessage(
 529                            ba.Lstr(
 530                                resource='getTicketsWindow.unavailableText'
 531                            ),
 532                            color=(1, 0, 0),
 533                        )
 534            # Real in-app purchase.
 535            else:
 536                if result['allow']:
 537                    ba.internal.purchase(item)
 538                else:
 539                    if result['reason'] == 'versionTooOld':
 540                        ba.playsound(ba.getsound('error'))
 541                        ba.screenmessage(
 542                            ba.Lstr(
 543                                resource='getTicketsWindow.versionTooOldText'
 544                            ),
 545                            color=(1, 0, 0),
 546                        )
 547                    else:
 548                        ba.playsound(ba.getsound('error'))
 549                        ba.screenmessage(
 550                            ba.Lstr(
 551                                resource='getTicketsWindow.unavailableText'
 552                            ),
 553                            color=(1, 0, 0),
 554                        )
 555
 556    def _do_purchase_check(
 557        self, item: str, is_ticket_purchase: bool = False
 558    ) -> None:
 559        from ba.internal import master_server_get
 560
 561        # Here we ping the server to ask if it's valid for us to
 562        # purchase this. Better to fail now than after we've
 563        # paid locally.
 564        app = ba.app
 565        master_server_get(
 566            'bsAccountPurchaseCheck',
 567            {
 568                'item': item,
 569                'platform': app.platform,
 570                'subplatform': app.subplatform,
 571                'version': app.version,
 572                'buildNumber': app.build_number,
 573                'purchaseType': 'ticket' if is_ticket_purchase else 'real',
 574            },
 575            callback=ba.WeakCall(
 576                self._purchase_check_result, item, is_ticket_purchase
 577            ),
 578        )
 579
 580    def buy(self, item: str) -> None:
 581        """Attempt to purchase the provided item."""
 582        from ba.internal import (
 583            get_available_sale_time,
 584            get_store_item_name_translated,
 585        )
 586        from bastd.ui import account
 587        from bastd.ui.confirm import ConfirmWindow
 588        from bastd.ui import getcurrency
 589
 590        # Prevent pressing buy within a few seconds of the last press
 591        # (gives the buttons time to disable themselves and whatnot).
 592        curtime = ba.time(ba.TimeType.REAL)
 593        if (
 594            self._last_buy_time is not None
 595            and (curtime - self._last_buy_time) < 2.0
 596        ):
 597            ba.playsound(ba.getsound('error'))
 598        else:
 599            if ba.internal.get_v1_account_state() != 'signed_in':
 600                account.show_sign_in_prompt()
 601            else:
 602                self._last_buy_time = curtime
 603
 604                # Merch is a special case - just a link.
 605                if item == 'merch':
 606                    url = ba.app.config.get('Merch Link')
 607                    if isinstance(url, str):
 608                        ba.open_url(url)
 609
 610                # Pro is an actual IAP, and the rest are ticket purchases.
 611                elif item == 'pro':
 612                    ba.playsound(ba.getsound('click01'))
 613
 614                    # Purchase either pro or pro_sale depending on whether
 615                    # there is a sale going on.
 616                    self._do_purchase_check(
 617                        'pro'
 618                        if get_available_sale_time('extras') is None
 619                        else 'pro_sale'
 620                    )
 621                else:
 622                    price = ba.internal.get_v1_account_misc_read_val(
 623                        'price.' + item, None
 624                    )
 625                    our_tickets = ba.internal.get_v1_account_ticket_count()
 626                    if price is not None and our_tickets < price:
 627                        ba.playsound(ba.getsound('error'))
 628                        getcurrency.show_get_tickets_prompt()
 629                    else:
 630
 631                        def do_it() -> None:
 632                            self._do_purchase_check(
 633                                item, is_ticket_purchase=True
 634                            )
 635
 636                        ba.playsound(ba.getsound('swish'))
 637                        ConfirmWindow(
 638                            ba.Lstr(
 639                                resource='store.purchaseConfirmText',
 640                                subs=[
 641                                    (
 642                                        '${ITEM}',
 643                                        get_store_item_name_translated(item),
 644                                    )
 645                                ],
 646                            ),
 647                            width=400,
 648                            height=120,
 649                            action=do_it,
 650                            ok_text=ba.Lstr(
 651                                resource='store.purchaseText',
 652                                fallback_resource='okText',
 653                            ),
 654                        )
 655
 656    def _print_already_own(self, charname: str) -> None:
 657        ba.screenmessage(
 658            ba.Lstr(
 659                resource=self._r + '.alreadyOwnText',
 660                subs=[('${NAME}', charname)],
 661            ),
 662            color=(1, 0, 0),
 663        )
 664        ba.playsound(ba.getsound('error'))
 665
 666    def update_buttons(self) -> None:
 667        """Update our buttons."""
 668        # pylint: disable=too-many-statements
 669        # pylint: disable=too-many-branches
 670        # pylint: disable=too-many-locals
 671        from ba.internal import get_available_sale_time
 672        from ba import SpecialChar
 673
 674        if not self._root_widget:
 675            return
 676        import datetime
 677
 678        sales_raw = ba.internal.get_v1_account_misc_read_val('sales', {})
 679        sales = {}
 680        try:
 681            # Look at the current set of sales; filter any with time remaining.
 682            for sale_item, sale_info in list(sales_raw.items()):
 683                to_end = (
 684                    datetime.datetime.utcfromtimestamp(sale_info['e'])
 685                    - datetime.datetime.utcnow()
 686                ).total_seconds()
 687                if to_end > 0:
 688                    sales[sale_item] = {
 689                        'to_end': to_end,
 690                        'original_price': sale_info['op'],
 691                    }
 692        except Exception:
 693            ba.print_exception('Error parsing sales.')
 694
 695        assert self.button_infos is not None
 696        for b_type, b_info in self.button_infos.items():
 697
 698            if b_type == 'merch':
 699                purchased = False
 700            elif b_type in ['upgrades.pro', 'pro']:
 701                purchased = ba.app.accounts_v1.have_pro()
 702            else:
 703                purchased = ba.internal.get_purchased(b_type)
 704
 705            sale_opacity = 0.0
 706            sale_title_text: str | ba.Lstr = ''
 707            sale_time_text: str | ba.Lstr = ''
 708
 709            if purchased:
 710                title_color = (0.8, 0.7, 0.9, 1.0)
 711                color = (0.63, 0.55, 0.78)
 712                extra_image_opacity = 0.5
 713                call = ba.WeakCall(self._print_already_own, b_info['name'])
 714                price_text = ''
 715                price_text_left = ''
 716                price_text_right = ''
 717                show_purchase_check = True
 718                description_color: Sequence[float] = (0.4, 1.0, 0.4, 0.4)
 719                description_color2: Sequence[float] = (0.0, 0.0, 0.0, 0.0)
 720                price_color = (0.5, 1, 0.5, 0.3)
 721            else:
 722                title_color = (0.7, 0.9, 0.7, 1.0)
 723                color = (0.4, 0.8, 0.1)
 724                extra_image_opacity = 1.0
 725                call = b_info['call'] if 'call' in b_info else None
 726                if b_type == 'merch':
 727                    price_text = ''
 728                    price_text_left = ''
 729                    price_text_right = ''
 730                elif b_type in ['upgrades.pro', 'pro']:
 731                    sale_time = get_available_sale_time('extras')
 732                    if sale_time is not None:
 733                        priceraw = ba.internal.get_price('pro')
 734                        price_text_left = (
 735                            priceraw if priceraw is not None else '?'
 736                        )
 737                        priceraw = ba.internal.get_price('pro_sale')
 738                        price_text_right = (
 739                            priceraw if priceraw is not None else '?'
 740                        )
 741                        sale_opacity = 1.0
 742                        price_text = ''
 743                        sale_title_text = ba.Lstr(resource='store.saleText')
 744                        sale_time_text = ba.timestring(
 745                            sale_time,
 746                            centi=False,
 747                            timeformat=ba.TimeFormat.MILLISECONDS,
 748                        )
 749                    else:
 750                        priceraw = ba.internal.get_price('pro')
 751                        price_text = priceraw if priceraw is not None else '?'
 752                        price_text_left = ''
 753                        price_text_right = ''
 754                else:
 755                    price = ba.internal.get_v1_account_misc_read_val(
 756                        'price.' + b_type, 0
 757                    )
 758
 759                    # Color the button differently if we cant afford this.
 760                    if ba.internal.get_v1_account_state() == 'signed_in':
 761                        if ba.internal.get_v1_account_ticket_count() < price:
 762                            color = (0.6, 0.61, 0.6)
 763                    price_text = ba.charstr(ba.SpecialChar.TICKET) + str(
 764                        ba.internal.get_v1_account_misc_read_val(
 765                            'price.' + b_type, '?'
 766                        )
 767                    )
 768                    price_text_left = ''
 769                    price_text_right = ''
 770
 771                    # TESTING:
 772                    if b_type in sales:
 773                        sale_opacity = 1.0
 774                        price_text_left = ba.charstr(SpecialChar.TICKET) + str(
 775                            sales[b_type]['original_price']
 776                        )
 777                        price_text_right = price_text
 778                        price_text = ''
 779                        sale_title_text = ba.Lstr(resource='store.saleText')
 780                        sale_time_text = ba.timestring(
 781                            int(sales[b_type]['to_end'] * 1000),
 782                            centi=False,
 783                            timeformat=ba.TimeFormat.MILLISECONDS,
 784                        )
 785
 786                description_color = (0.5, 1.0, 0.5)
 787                description_color2 = (0.3, 1.0, 1.0)
 788                price_color = (0.2, 1, 0.2, 1.0)
 789                show_purchase_check = False
 790
 791            if 'title_text' in b_info:
 792                ba.textwidget(edit=b_info['title_text'], color=title_color)
 793            if 'purchase_check' in b_info:
 794                ba.imagewidget(
 795                    edit=b_info['purchase_check'],
 796                    opacity=1.0 if show_purchase_check else 0.0,
 797                )
 798            if 'price_widget' in b_info:
 799                ba.textwidget(
 800                    edit=b_info['price_widget'],
 801                    text=price_text,
 802                    color=price_color,
 803                )
 804            if 'price_widget_left' in b_info:
 805                ba.textwidget(
 806                    edit=b_info['price_widget_left'], text=price_text_left
 807                )
 808            if 'price_widget_right' in b_info:
 809                ba.textwidget(
 810                    edit=b_info['price_widget_right'], text=price_text_right
 811                )
 812            if 'price_slash_widget' in b_info:
 813                ba.imagewidget(
 814                    edit=b_info['price_slash_widget'], opacity=sale_opacity
 815                )
 816            if 'sale_bg_widget' in b_info:
 817                ba.imagewidget(
 818                    edit=b_info['sale_bg_widget'], opacity=sale_opacity
 819                )
 820            if 'sale_title_widget' in b_info:
 821                ba.textwidget(
 822                    edit=b_info['sale_title_widget'], text=sale_title_text
 823                )
 824            if 'sale_time_widget' in b_info:
 825                ba.textwidget(
 826                    edit=b_info['sale_time_widget'], text=sale_time_text
 827                )
 828            if 'button' in b_info:
 829                ba.buttonwidget(
 830                    edit=b_info['button'], color=color, on_activate_call=call
 831                )
 832            if 'extra_backings' in b_info:
 833                for bck in b_info['extra_backings']:
 834                    ba.imagewidget(
 835                        edit=bck, color=color, opacity=extra_image_opacity
 836                    )
 837            if 'extra_images' in b_info:
 838                for img in b_info['extra_images']:
 839                    ba.imagewidget(edit=img, opacity=extra_image_opacity)
 840            if 'extra_texts' in b_info:
 841                for etxt in b_info['extra_texts']:
 842                    ba.textwidget(edit=etxt, color=description_color)
 843            if 'extra_texts_2' in b_info:
 844                for etxt in b_info['extra_texts_2']:
 845                    ba.textwidget(edit=etxt, color=description_color2)
 846            if 'descriptionText' in b_info:
 847                ba.textwidget(
 848                    edit=b_info['descriptionText'], color=description_color
 849                )
 850
 851    def _on_response(self, data: dict[str, Any] | None) -> None:
 852        # pylint: disable=too-many-statements
 853
 854        # clear status text..
 855        if self._status_textwidget:
 856            self._status_textwidget.delete()
 857            self._status_textwidget_update_timer = None
 858
 859        if data is None:
 860            self._status_textwidget = ba.textwidget(
 861                parent=self._root_widget,
 862                position=(self._width * 0.5, self._height * 0.5),
 863                size=(0, 0),
 864                scale=1.3,
 865                transition_delay=0.1,
 866                color=(1, 0.3, 0.3, 1.0),
 867                h_align='center',
 868                v_align='center',
 869                text=ba.Lstr(resource=self._r + '.loadErrorText'),
 870                maxwidth=self._scroll_width * 0.9,
 871            )
 872        else:
 873
 874            class _Store:
 875                def __init__(
 876                    self,
 877                    store_window: StoreBrowserWindow,
 878                    sdata: dict[str, Any],
 879                    width: float,
 880                ):
 881                    from ba.internal import (
 882                        get_store_item_display_size,
 883                        get_store_layout,
 884                    )
 885
 886                    self._store_window = store_window
 887                    self._width = width
 888                    store_data = get_store_layout()
 889                    self._tab = sdata['tab']
 890                    self._sections = copy.deepcopy(store_data[sdata['tab']])
 891                    self._height: float | None = None
 892
 893                    uiscale = ba.app.ui.uiscale
 894
 895                    # Pre-calc a few things and add them to store-data.
 896                    for section in self._sections:
 897                        if self._tab == 'characters':
 898                            dummy_name = 'characters.foo'
 899                        elif self._tab == 'extras':
 900                            dummy_name = 'pro'
 901                        elif self._tab == 'maps':
 902                            dummy_name = 'maps.foo'
 903                        elif self._tab == 'icons':
 904                            dummy_name = 'icons.foo'
 905                        else:
 906                            dummy_name = ''
 907                        section['button_size'] = get_store_item_display_size(
 908                            dummy_name
 909                        )
 910                        section['v_spacing'] = (
 911                            -25
 912                            if (
 913                                self._tab == 'extras'
 914                                and uiscale is ba.UIScale.SMALL
 915                            )
 916                            else -17
 917                            if self._tab == 'characters'
 918                            else 0
 919                        )
 920                        if 'title' not in section:
 921                            section['title'] = ''
 922                        section['x_offs'] = (
 923                            130
 924                            if self._tab == 'extras'
 925                            else 270
 926                            if self._tab == 'maps'
 927                            else 0
 928                        )
 929                        section['y_offs'] = (
 930                            20
 931                            if (
 932                                self._tab == 'extras'
 933                                and uiscale is ba.UIScale.SMALL
 934                                and ba.app.config.get('Merch Link')
 935                            )
 936                            else 55
 937                            if (
 938                                self._tab == 'extras'
 939                                and uiscale is ba.UIScale.SMALL
 940                            )
 941                            else -20
 942                            if self._tab == 'icons'
 943                            else 0
 944                        )
 945
 946                def instantiate(
 947                    self, scrollwidget: ba.Widget, tab_button: ba.Widget
 948                ) -> None:
 949                    """Create the store."""
 950                    # pylint: disable=too-many-locals
 951                    # pylint: disable=too-many-branches
 952                    # pylint: disable=too-many-nested-blocks
 953                    from bastd.ui.store.item import (
 954                        instantiate_store_item_display,
 955                    )
 956
 957                    title_spacing = 40
 958                    button_border = 20
 959                    button_spacing = 4
 960                    boffs_h = 40
 961                    self._height = 80.0
 962
 963                    # Calc total height.
 964                    for i, section in enumerate(self._sections):
 965                        if section['title'] != '':
 966                            assert self._height is not None
 967                            self._height += title_spacing
 968                        b_width, b_height = section['button_size']
 969                        b_column_count = int(
 970                            math.floor(
 971                                (self._width - boffs_h - 20)
 972                                / (b_width + button_spacing)
 973                            )
 974                        )
 975                        b_row_count = int(
 976                            math.ceil(
 977                                float(len(section['items'])) / b_column_count
 978                            )
 979                        )
 980                        b_height_total = (
 981                            2 * button_border
 982                            + b_row_count * b_height
 983                            + (b_row_count - 1) * section['v_spacing']
 984                        )
 985                        self._height += b_height_total
 986
 987                    assert self._height is not None
 988                    cnt2 = ba.containerwidget(
 989                        parent=scrollwidget,
 990                        scale=1.0,
 991                        size=(self._width, self._height),
 992                        background=False,
 993                        claims_left_right=True,
 994                        claims_tab=True,
 995                        selection_loops_to_parent=True,
 996                    )
 997                    v = self._height - 20
 998
 999                    if self._tab == 'characters':
1000                        txt = ba.Lstr(
1001                            resource='store.howToSwitchCharactersText',
1002                            subs=[
1003                                (
1004                                    '${SETTINGS}',
1005                                    ba.Lstr(
1006                                        resource=(
1007                                            'accountSettingsWindow.titleText'
1008                                        )
1009                                    ),
1010                                ),
1011                                (
1012                                    '${PLAYER_PROFILES}',
1013                                    ba.Lstr(
1014                                        resource=(
1015                                            'playerProfilesWindow.titleText'
1016                                        )
1017                                    ),
1018                                ),
1019                            ],
1020                        )
1021                        ba.textwidget(
1022                            parent=cnt2,
1023                            text=txt,
1024                            size=(0, 0),
1025                            position=(self._width * 0.5, self._height - 28),
1026                            h_align='center',
1027                            v_align='center',
1028                            color=(0.7, 1, 0.7, 0.4),
1029                            scale=0.7,
1030                            shadow=0,
1031                            flatness=1.0,
1032                            maxwidth=700,
1033                            transition_delay=0.4,
1034                        )
1035                    elif self._tab == 'icons':
1036                        txt = ba.Lstr(
1037                            resource='store.howToUseIconsText',
1038                            subs=[
1039                                (
1040                                    '${SETTINGS}',
1041                                    ba.Lstr(resource='mainMenu.settingsText'),
1042                                ),
1043                                (
1044                                    '${PLAYER_PROFILES}',
1045                                    ba.Lstr(
1046                                        resource=(
1047                                            'playerProfilesWindow.titleText'
1048                                        )
1049                                    ),
1050                                ),
1051                            ],
1052                        )
1053                        ba.textwidget(
1054                            parent=cnt2,
1055                            text=txt,
1056                            size=(0, 0),
1057                            position=(self._width * 0.5, self._height - 28),
1058                            h_align='center',
1059                            v_align='center',
1060                            color=(0.7, 1, 0.7, 0.4),
1061                            scale=0.7,
1062                            shadow=0,
1063                            flatness=1.0,
1064                            maxwidth=700,
1065                            transition_delay=0.4,
1066                        )
1067                    elif self._tab == 'maps':
1068                        assert self._width is not None
1069                        assert self._height is not None
1070                        txt = ba.Lstr(resource='store.howToUseMapsText')
1071                        ba.textwidget(
1072                            parent=cnt2,
1073                            text=txt,
1074                            size=(0, 0),
1075                            position=(self._width * 0.5, self._height - 28),
1076                            h_align='center',
1077                            v_align='center',
1078                            color=(0.7, 1, 0.7, 0.4),
1079                            scale=0.7,
1080                            shadow=0,
1081                            flatness=1.0,
1082                            maxwidth=700,
1083                            transition_delay=0.4,
1084                        )
1085
1086                    prev_row_buttons: list | None = None
1087                    this_row_buttons = []
1088
1089                    delay = 0.3
1090                    for section in self._sections:
1091                        if section['title'] != '':
1092                            ba.textwidget(
1093                                parent=cnt2,
1094                                position=(60, v - title_spacing * 0.8),
1095                                size=(0, 0),
1096                                scale=1.0,
1097                                transition_delay=delay,
1098                                color=(0.7, 0.9, 0.7, 1),
1099                                h_align='left',
1100                                v_align='center',
1101                                text=ba.Lstr(resource=section['title']),
1102                                maxwidth=self._width * 0.7,
1103                            )
1104                            v -= title_spacing
1105                        delay = max(0.100, delay - 0.100)
1106                        v -= button_border
1107                        b_width, b_height = section['button_size']
1108                        b_count = len(section['items'])
1109                        b_column_count = int(
1110                            math.floor(
1111                                (self._width - boffs_h - 20)
1112                                / (b_width + button_spacing)
1113                            )
1114                        )
1115                        col = 0
1116                        item: dict[str, Any]
1117                        assert self._store_window.button_infos is not None
1118                        for i, item_name in enumerate(section['items']):
1119                            item = self._store_window.button_infos[
1120                                item_name
1121                            ] = {}
1122                            item['call'] = ba.WeakCall(
1123                                self._store_window.buy, item_name
1124                            )
1125                            if 'x_offs' in section:
1126                                boffs_h2 = section['x_offs']
1127                            else:
1128                                boffs_h2 = 0
1129
1130                            if 'y_offs' in section:
1131                                boffs_v2 = section['y_offs']
1132                            else:
1133                                boffs_v2 = 0
1134                            b_pos = (
1135                                boffs_h
1136                                + boffs_h2
1137                                + (b_width + button_spacing) * col,
1138                                v - b_height + boffs_v2,
1139                            )
1140                            instantiate_store_item_display(
1141                                item_name,
1142                                item,
1143                                parent_widget=cnt2,
1144                                b_pos=b_pos,
1145                                boffs_h=boffs_h,
1146                                b_width=b_width,
1147                                b_height=b_height,
1148                                boffs_h2=boffs_h2,
1149                                boffs_v2=boffs_v2,
1150                                delay=delay,
1151                            )
1152                            btn = item['button']
1153                            delay = max(0.1, delay - 0.1)
1154                            this_row_buttons.append(btn)
1155
1156                            # Wire this button to the equivalent in the
1157                            # previous row.
1158                            if prev_row_buttons is not None:
1159                                if len(prev_row_buttons) > col:
1160                                    ba.widget(
1161                                        edit=btn,
1162                                        up_widget=prev_row_buttons[col],
1163                                    )
1164                                    ba.widget(
1165                                        edit=prev_row_buttons[col],
1166                                        down_widget=btn,
1167                                    )
1168
1169                                    # If we're the last button in our row,
1170                                    # wire any in the previous row past
1171                                    # our position to go to us if down is
1172                                    # pressed.
1173                                    if (
1174                                        col + 1 == b_column_count
1175                                        or i == b_count - 1
1176                                    ):
1177                                        for b_prev in prev_row_buttons[
1178                                            col + 1 :
1179                                        ]:
1180                                            ba.widget(
1181                                                edit=b_prev, down_widget=btn
1182                                            )
1183                                else:
1184                                    ba.widget(
1185                                        edit=btn, up_widget=prev_row_buttons[-1]
1186                                    )
1187                            else:
1188                                ba.widget(edit=btn, up_widget=tab_button)
1189
1190                            col += 1
1191                            if col == b_column_count or i == b_count - 1:
1192                                prev_row_buttons = this_row_buttons
1193                                this_row_buttons = []
1194                                col = 0
1195                                v -= b_height
1196                                if i < b_count - 1:
1197                                    v -= section['v_spacing']
1198
1199                        v -= button_border
1200
1201                    # Set a timer to update these buttons periodically as long
1202                    # as we're alive (so if we buy one it will grey out, etc).
1203                    self._store_window.update_buttons_timer = ba.Timer(
1204                        0.5,
1205                        ba.WeakCall(self._store_window.update_buttons),
1206                        repeat=True,
1207                        timetype=ba.TimeType.REAL,
1208                    )
1209
1210                    # Also update them immediately.
1211                    self._store_window.update_buttons()
1212
1213            if self._current_tab in (
1214                self.TabID.EXTRAS,
1215                self.TabID.MINIGAMES,
1216                self.TabID.CHARACTERS,
1217                self.TabID.MAPS,
1218                self.TabID.ICONS,
1219            ):
1220                store = _Store(self, data, self._scroll_width)
1221                assert self._scrollwidget is not None
1222                store.instantiate(
1223                    scrollwidget=self._scrollwidget,
1224                    tab_button=self._tab_row.tabs[self._current_tab].button,
1225                )
1226            else:
1227                cnt = ba.containerwidget(
1228                    parent=self._scrollwidget,
1229                    scale=1.0,
1230                    size=(self._scroll_width, self._scroll_height * 0.95),
1231                    background=False,
1232                    claims_left_right=True,
1233                    claims_tab=True,
1234                    selection_loops_to_parent=True,
1235                )
1236                self._status_textwidget = ba.textwidget(
1237                    parent=cnt,
1238                    position=(
1239                        self._scroll_width * 0.5,
1240                        self._scroll_height * 0.5,
1241                    ),
1242                    size=(0, 0),
1243                    scale=1.3,
1244                    transition_delay=0.1,
1245                    color=(1, 1, 0.3, 1.0),
1246                    h_align='center',
1247                    v_align='center',
1248                    text=ba.Lstr(resource=self._r + '.comingSoonText'),
1249                    maxwidth=self._scroll_width * 0.9,
1250                )
1251
1252    def _save_state(self) -> None:
1253        try:
1254            sel = self._root_widget.get_selected_child()
1255            selected_tab_ids = [
1256                tab_id
1257                for tab_id, tab in self._tab_row.tabs.items()
1258                if sel == tab.button
1259            ]
1260            if sel == self._get_tickets_button:
1261                sel_name = 'GetTickets'
1262            elif sel == self._scrollwidget:
1263                sel_name = 'Scroll'
1264            elif sel == self._back_button:
1265                sel_name = 'Back'
1266            elif selected_tab_ids:
1267                assert len(selected_tab_ids) == 1
1268                sel_name = f'Tab:{selected_tab_ids[0].value}'
1269            else:
1270                raise ValueError(f'unrecognized selection \'{sel}\'')
1271            ba.app.ui.window_states[type(self)] = {
1272                'sel_name': sel_name,
1273            }
1274        except Exception:
1275            ba.print_exception(f'Error saving state for {self}.')
1276
1277    def _restore_state(self) -> None:
1278        from efro.util import enum_by_value
1279
1280        try:
1281            sel: ba.Widget | None
1282            sel_name = ba.app.ui.window_states.get(type(self), {}).get(
1283                'sel_name'
1284            )
1285            assert isinstance(sel_name, (str, type(None)))
1286
1287            try:
1288                current_tab = enum_by_value(
1289                    self.TabID, ba.app.config.get('Store Tab')
1290                )
1291            except ValueError:
1292                current_tab = self.TabID.CHARACTERS
1293
1294            if self._show_tab is not None:
1295                current_tab = self._show_tab
1296            if sel_name == 'GetTickets' and self._get_tickets_button:
1297                sel = self._get_tickets_button
1298            elif sel_name == 'Back':
1299                sel = self._back_button
1300            elif sel_name == 'Scroll':
1301                sel = self._scrollwidget
1302            elif isinstance(sel_name, str) and sel_name.startswith('Tab:'):
1303                try:
1304                    sel_tab_id = enum_by_value(
1305                        self.TabID, sel_name.split(':')[-1]
1306                    )
1307                except ValueError:
1308                    sel_tab_id = self.TabID.CHARACTERS
1309                sel = self._tab_row.tabs[sel_tab_id].button
1310            else:
1311                sel = self._tab_row.tabs[current_tab].button
1312
1313            # If we were requested to show a tab, select it too..
1314            if (
1315                self._show_tab is not None
1316                and self._show_tab in self._tab_row.tabs
1317            ):
1318                sel = self._tab_row.tabs[self._show_tab].button
1319            self._set_tab(current_tab)
1320            if sel is not None:
1321                ba.containerwidget(edit=self._root_widget, selected_child=sel)
1322        except Exception:
1323            ba.print_exception(f'Error restoring state for {self}.')
1324
1325    def _on_get_more_tickets_press(self) -> None:
1326        # pylint: disable=cyclic-import
1327        from bastd.ui.account import show_sign_in_prompt
1328        from bastd.ui.getcurrency import GetCurrencyWindow
1329
1330        if ba.internal.get_v1_account_state() != 'signed_in':
1331            show_sign_in_prompt()
1332            return
1333        self._save_state()
1334        ba.containerwidget(edit=self._root_widget, transition='out_left')
1335        window = GetCurrencyWindow(
1336            from_modal_store=self._modal,
1337            store_back_location=self._back_location,
1338        ).get_root_widget()
1339        if not self._modal:
1340            ba.app.ui.set_main_menu_window(window)
1341
1342    def _back(self) -> None:
1343        # pylint: disable=cyclic-import
1344        from bastd.ui.coop.browser import CoopBrowserWindow
1345        from bastd.ui.mainmenu import MainMenuWindow
1346
1347        self._save_state()
1348        ba.containerwidget(
1349            edit=self._root_widget, transition=self._transition_out
1350        )
1351        if not self._modal:
1352            if self._back_location == 'CoopBrowserWindow':
1353                ba.app.ui.set_main_menu_window(
1354                    CoopBrowserWindow(transition='in_left').get_root_widget()
1355                )
1356            else:
1357                ba.app.ui.set_main_menu_window(
1358                    MainMenuWindow(transition='in_left').get_root_widget()
1359                )
1360        if self._on_close_call is not None:
1361            self._on_close_call()

Window for browsing the store.

StoreBrowserWindow( transition: str = 'in_right', modal: bool = False, show_tab: bastd.ui.store.browser.StoreBrowserWindow.TabID | None = None, on_close_call: Optional[Callable[[], Any]] = None, back_location: str | None = None, origin_widget: _ba.Widget | None = None)
 40    def __init__(
 41        self,
 42        transition: str = 'in_right',
 43        modal: bool = False,
 44        show_tab: StoreBrowserWindow.TabID | None = None,
 45        on_close_call: Callable[[], Any] | None = None,
 46        back_location: str | None = None,
 47        origin_widget: ba.Widget | None = None,
 48    ):
 49        # pylint: disable=too-many-statements
 50        # pylint: disable=too-many-locals
 51        from bastd.ui.tabs import TabRow
 52        from ba import SpecialChar
 53
 54        app = ba.app
 55        uiscale = app.ui.uiscale
 56
 57        ba.set_analytics_screen('Store Window')
 58
 59        scale_origin: tuple[float, float] | None
 60
 61        # If they provided an origin-widget, scale up from that.
 62        if origin_widget is not None:
 63            self._transition_out = 'out_scale'
 64            scale_origin = origin_widget.get_screen_space_center()
 65            transition = 'in_scale'
 66        else:
 67            self._transition_out = 'out_right'
 68            scale_origin = None
 69
 70        self.button_infos: dict[str, dict[str, Any]] | None = None
 71        self.update_buttons_timer: ba.Timer | None = None
 72        self._status_textwidget_update_timer = None
 73
 74        self._back_location = back_location
 75        self._on_close_call = on_close_call
 76        self._show_tab = show_tab
 77        self._modal = modal
 78        self._width = 1240 if uiscale is ba.UIScale.SMALL else 1040
 79        self._x_inset = x_inset = 100 if uiscale is ba.UIScale.SMALL else 0
 80        self._height = (
 81            578
 82            if uiscale is ba.UIScale.SMALL
 83            else 645
 84            if uiscale is ba.UIScale.MEDIUM
 85            else 800
 86        )
 87        self._current_tab: StoreBrowserWindow.TabID | None = None
 88        extra_top = 30 if uiscale is ba.UIScale.SMALL else 0
 89
 90        self._request: Any = None
 91        self._r = 'store'
 92        self._last_buy_time: float | None = None
 93
 94        super().__init__(
 95            root_widget=ba.containerwidget(
 96                size=(self._width, self._height + extra_top),
 97                transition=transition,
 98                toolbar_visibility='menu_full',
 99                scale=(
100                    1.3
101                    if uiscale is ba.UIScale.SMALL
102                    else 0.9
103                    if uiscale is ba.UIScale.MEDIUM
104                    else 0.8
105                ),
106                scale_origin_stack_offset=scale_origin,
107                stack_offset=(
108                    (0, -5)
109                    if uiscale is ba.UIScale.SMALL
110                    else (0, 0)
111                    if uiscale is ba.UIScale.MEDIUM
112                    else (0, 0)
113                ),
114            )
115        )
116
117        self._back_button = btn = ba.buttonwidget(
118            parent=self._root_widget,
119            position=(70 + x_inset, self._height - 74),
120            size=(140, 60),
121            scale=1.1,
122            autoselect=True,
123            label=ba.Lstr(resource='doneText' if self._modal else 'backText'),
124            button_type=None if self._modal else 'back',
125            on_activate_call=self._back,
126        )
127        ba.containerwidget(edit=self._root_widget, cancel_button=btn)
128
129        self._ticket_count_text: ba.Widget | None = None
130        self._get_tickets_button: ba.Widget | None = None
131
132        if ba.app.allow_ticket_purchases:
133            self._get_tickets_button = ba.buttonwidget(
134                parent=self._root_widget,
135                size=(210, 65),
136                on_activate_call=self._on_get_more_tickets_press,
137                autoselect=True,
138                scale=0.9,
139                text_scale=1.4,
140                left_widget=self._back_button,
141                color=(0.7, 0.5, 0.85),
142                textcolor=(0.2, 1.0, 0.2),
143                label=ba.Lstr(resource='getTicketsWindow.titleText'),
144            )
145        else:
146            self._ticket_count_text = ba.textwidget(
147                parent=self._root_widget,
148                size=(210, 64),
149                color=(0.2, 1.0, 0.2),
150                h_align='center',
151                v_align='center',
152            )
153
154        # Move this dynamically to keep it out of the way of the party icon.
155        self._update_get_tickets_button_pos()
156        self._get_ticket_pos_update_timer = ba.Timer(
157            1.0,
158            ba.WeakCall(self._update_get_tickets_button_pos),
159            repeat=True,
160            timetype=ba.TimeType.REAL,
161        )
162        if self._get_tickets_button:
163            ba.widget(
164                edit=self._back_button, right_widget=self._get_tickets_button
165            )
166        self._ticket_text_update_timer = ba.Timer(
167            1.0,
168            ba.WeakCall(self._update_tickets_text),
169            timetype=ba.TimeType.REAL,
170            repeat=True,
171        )
172        self._update_tickets_text()
173
174        app = ba.app
175        if app.platform in ['mac', 'ios'] and app.subplatform == 'appstore':
176            ba.buttonwidget(
177                parent=self._root_widget,
178                position=(self._width * 0.5 - 70, 16),
179                size=(230, 50),
180                scale=0.65,
181                on_activate_call=ba.WeakCall(self._restore_purchases),
182                color=(0.35, 0.3, 0.4),
183                selectable=False,
184                textcolor=(0.55, 0.5, 0.6),
185                label=ba.Lstr(resource='getTicketsWindow.restorePurchasesText'),
186            )
187
188        ba.textwidget(
189            parent=self._root_widget,
190            position=(self._width * 0.5, self._height - 44),
191            size=(0, 0),
192            color=app.ui.title_color,
193            scale=1.5,
194            h_align='center',
195            v_align='center',
196            text=ba.Lstr(resource='storeText'),
197            maxwidth=420,
198        )
199
200        if not self._modal:
201            ba.buttonwidget(
202                edit=self._back_button,
203                button_type='backSmall',
204                size=(60, 60),
205                label=ba.charstr(SpecialChar.BACK),
206            )
207
208        scroll_buffer_h = 130 + 2 * x_inset
209        tab_buffer_h = 250 + 2 * x_inset
210
211        tabs_def = [
212            (self.TabID.EXTRAS, ba.Lstr(resource=self._r + '.extrasText')),
213            (self.TabID.MAPS, ba.Lstr(resource=self._r + '.mapsText')),
214            (
215                self.TabID.MINIGAMES,
216                ba.Lstr(resource=self._r + '.miniGamesText'),
217            ),
218            (
219                self.TabID.CHARACTERS,
220                ba.Lstr(resource=self._r + '.charactersText'),
221            ),
222            (self.TabID.ICONS, ba.Lstr(resource=self._r + '.iconsText')),
223        ]
224
225        self._tab_row = TabRow(
226            self._root_widget,
227            tabs_def,
228            pos=(tab_buffer_h * 0.5, self._height - 130),
229            size=(self._width - tab_buffer_h, 50),
230            on_select_call=self._set_tab,
231        )
232
233        self._purchasable_count_widgets: dict[
234            StoreBrowserWindow.TabID, dict[str, Any]
235        ] = {}
236
237        # Create our purchasable-items tags and have them update over time.
238        for tab_id, tab in self._tab_row.tabs.items():
239            pos = tab.position
240            size = tab.size
241            button = tab.button
242            rad = 10
243            center = (pos[0] + 0.1 * size[0], pos[1] + 0.9 * size[1])
244            img = ba.imagewidget(
245                parent=self._root_widget,
246                position=(center[0] - rad * 1.04, center[1] - rad * 1.15),
247                size=(rad * 2.2, rad * 2.2),
248                texture=ba.gettexture('circleShadow'),
249                color=(1, 0, 0),
250            )
251            txt = ba.textwidget(
252                parent=self._root_widget,
253                position=center,
254                size=(0, 0),
255                h_align='center',
256                v_align='center',
257                maxwidth=1.4 * rad,
258                scale=0.6,
259                shadow=1.0,
260                flatness=1.0,
261            )
262            rad = 20
263            sale_img = ba.imagewidget(
264                parent=self._root_widget,
265                position=(center[0] - rad, center[1] - rad),
266                size=(rad * 2, rad * 2),
267                draw_controller=button,
268                texture=ba.gettexture('circleZigZag'),
269                color=(0.5, 0, 1.0),
270            )
271            sale_title_text = ba.textwidget(
272                parent=self._root_widget,
273                position=(center[0], center[1] + 0.24 * rad),
274                size=(0, 0),
275                h_align='center',
276                v_align='center',
277                draw_controller=button,
278                maxwidth=1.4 * rad,
279                scale=0.6,
280                shadow=0.0,
281                flatness=1.0,
282                color=(0, 1, 0),
283            )
284            sale_time_text = ba.textwidget(
285                parent=self._root_widget,
286                position=(center[0], center[1] - 0.29 * rad),
287                size=(0, 0),
288                h_align='center',
289                v_align='center',
290                draw_controller=button,
291                maxwidth=1.4 * rad,
292                scale=0.4,
293                shadow=0.0,
294                flatness=1.0,
295                color=(0, 1, 0),
296            )
297            self._purchasable_count_widgets[tab_id] = {
298                'img': img,
299                'text': txt,
300                'sale_img': sale_img,
301                'sale_title_text': sale_title_text,
302                'sale_time_text': sale_time_text,
303            }
304        self._tab_update_timer = ba.Timer(
305            1.0,
306            ba.WeakCall(self._update_tabs),
307            timetype=ba.TimeType.REAL,
308            repeat=True,
309        )
310        self._update_tabs()
311
312        if self._get_tickets_button:
313            last_tab_button = self._tab_row.tabs[tabs_def[-1][0]].button
314            ba.widget(
315                edit=self._get_tickets_button, down_widget=last_tab_button
316            )
317            ba.widget(
318                edit=last_tab_button,
319                up_widget=self._get_tickets_button,
320                right_widget=self._get_tickets_button,
321            )
322
323        self._scroll_width = self._width - scroll_buffer_h
324        self._scroll_height = self._height - 180
325
326        self._scrollwidget: ba.Widget | None = None
327        self._status_textwidget: ba.Widget | None = None
328        self._restore_state()
def buy(self, item: str) -> None:
580    def buy(self, item: str) -> None:
581        """Attempt to purchase the provided item."""
582        from ba.internal import (
583            get_available_sale_time,
584            get_store_item_name_translated,
585        )
586        from bastd.ui import account
587        from bastd.ui.confirm import ConfirmWindow
588        from bastd.ui import getcurrency
589
590        # Prevent pressing buy within a few seconds of the last press
591        # (gives the buttons time to disable themselves and whatnot).
592        curtime = ba.time(ba.TimeType.REAL)
593        if (
594            self._last_buy_time is not None
595            and (curtime - self._last_buy_time) < 2.0
596        ):
597            ba.playsound(ba.getsound('error'))
598        else:
599            if ba.internal.get_v1_account_state() != 'signed_in':
600                account.show_sign_in_prompt()
601            else:
602                self._last_buy_time = curtime
603
604                # Merch is a special case - just a link.
605                if item == 'merch':
606                    url = ba.app.config.get('Merch Link')
607                    if isinstance(url, str):
608                        ba.open_url(url)
609
610                # Pro is an actual IAP, and the rest are ticket purchases.
611                elif item == 'pro':
612                    ba.playsound(ba.getsound('click01'))
613
614                    # Purchase either pro or pro_sale depending on whether
615                    # there is a sale going on.
616                    self._do_purchase_check(
617                        'pro'
618                        if get_available_sale_time('extras') is None
619                        else 'pro_sale'
620                    )
621                else:
622                    price = ba.internal.get_v1_account_misc_read_val(
623                        'price.' + item, None
624                    )
625                    our_tickets = ba.internal.get_v1_account_ticket_count()
626                    if price is not None and our_tickets < price:
627                        ba.playsound(ba.getsound('error'))
628                        getcurrency.show_get_tickets_prompt()
629                    else:
630
631                        def do_it() -> None:
632                            self._do_purchase_check(
633                                item, is_ticket_purchase=True
634                            )
635
636                        ba.playsound(ba.getsound('swish'))
637                        ConfirmWindow(
638                            ba.Lstr(
639                                resource='store.purchaseConfirmText',
640                                subs=[
641                                    (
642                                        '${ITEM}',
643                                        get_store_item_name_translated(item),
644                                    )
645                                ],
646                            ),
647                            width=400,
648                            height=120,
649                            action=do_it,
650                            ok_text=ba.Lstr(
651                                resource='store.purchaseText',
652                                fallback_resource='okText',
653                            ),
654                        )

Attempt to purchase the provided item.

def update_buttons(self) -> None:
666    def update_buttons(self) -> None:
667        """Update our buttons."""
668        # pylint: disable=too-many-statements
669        # pylint: disable=too-many-branches
670        # pylint: disable=too-many-locals
671        from ba.internal import get_available_sale_time
672        from ba import SpecialChar
673
674        if not self._root_widget:
675            return
676        import datetime
677
678        sales_raw = ba.internal.get_v1_account_misc_read_val('sales', {})
679        sales = {}
680        try:
681            # Look at the current set of sales; filter any with time remaining.
682            for sale_item, sale_info in list(sales_raw.items()):
683                to_end = (
684                    datetime.datetime.utcfromtimestamp(sale_info['e'])
685                    - datetime.datetime.utcnow()
686                ).total_seconds()
687                if to_end > 0:
688                    sales[sale_item] = {
689                        'to_end': to_end,
690                        'original_price': sale_info['op'],
691                    }
692        except Exception:
693            ba.print_exception('Error parsing sales.')
694
695        assert self.button_infos is not None
696        for b_type, b_info in self.button_infos.items():
697
698            if b_type == 'merch':
699                purchased = False
700            elif b_type in ['upgrades.pro', 'pro']:
701                purchased = ba.app.accounts_v1.have_pro()
702            else:
703                purchased = ba.internal.get_purchased(b_type)
704
705            sale_opacity = 0.0
706            sale_title_text: str | ba.Lstr = ''
707            sale_time_text: str | ba.Lstr = ''
708
709            if purchased:
710                title_color = (0.8, 0.7, 0.9, 1.0)
711                color = (0.63, 0.55, 0.78)
712                extra_image_opacity = 0.5
713                call = ba.WeakCall(self._print_already_own, b_info['name'])
714                price_text = ''
715                price_text_left = ''
716                price_text_right = ''
717                show_purchase_check = True
718                description_color: Sequence[float] = (0.4, 1.0, 0.4, 0.4)
719                description_color2: Sequence[float] = (0.0, 0.0, 0.0, 0.0)
720                price_color = (0.5, 1, 0.5, 0.3)
721            else:
722                title_color = (0.7, 0.9, 0.7, 1.0)
723                color = (0.4, 0.8, 0.1)
724                extra_image_opacity = 1.0
725                call = b_info['call'] if 'call' in b_info else None
726                if b_type == 'merch':
727                    price_text = ''
728                    price_text_left = ''
729                    price_text_right = ''
730                elif b_type in ['upgrades.pro', 'pro']:
731                    sale_time = get_available_sale_time('extras')
732                    if sale_time is not None:
733                        priceraw = ba.internal.get_price('pro')
734                        price_text_left = (
735                            priceraw if priceraw is not None else '?'
736                        )
737                        priceraw = ba.internal.get_price('pro_sale')
738                        price_text_right = (
739                            priceraw if priceraw is not None else '?'
740                        )
741                        sale_opacity = 1.0
742                        price_text = ''
743                        sale_title_text = ba.Lstr(resource='store.saleText')
744                        sale_time_text = ba.timestring(
745                            sale_time,
746                            centi=False,
747                            timeformat=ba.TimeFormat.MILLISECONDS,
748                        )
749                    else:
750                        priceraw = ba.internal.get_price('pro')
751                        price_text = priceraw if priceraw is not None else '?'
752                        price_text_left = ''
753                        price_text_right = ''
754                else:
755                    price = ba.internal.get_v1_account_misc_read_val(
756                        'price.' + b_type, 0
757                    )
758
759                    # Color the button differently if we cant afford this.
760                    if ba.internal.get_v1_account_state() == 'signed_in':
761                        if ba.internal.get_v1_account_ticket_count() < price:
762                            color = (0.6, 0.61, 0.6)
763                    price_text = ba.charstr(ba.SpecialChar.TICKET) + str(
764                        ba.internal.get_v1_account_misc_read_val(
765                            'price.' + b_type, '?'
766                        )
767                    )
768                    price_text_left = ''
769                    price_text_right = ''
770
771                    # TESTING:
772                    if b_type in sales:
773                        sale_opacity = 1.0
774                        price_text_left = ba.charstr(SpecialChar.TICKET) + str(
775                            sales[b_type]['original_price']
776                        )
777                        price_text_right = price_text
778                        price_text = ''
779                        sale_title_text = ba.Lstr(resource='store.saleText')
780                        sale_time_text = ba.timestring(
781                            int(sales[b_type]['to_end'] * 1000),
782                            centi=False,
783                            timeformat=ba.TimeFormat.MILLISECONDS,
784                        )
785
786                description_color = (0.5, 1.0, 0.5)
787                description_color2 = (0.3, 1.0, 1.0)
788                price_color = (0.2, 1, 0.2, 1.0)
789                show_purchase_check = False
790
791            if 'title_text' in b_info:
792                ba.textwidget(edit=b_info['title_text'], color=title_color)
793            if 'purchase_check' in b_info:
794                ba.imagewidget(
795                    edit=b_info['purchase_check'],
796                    opacity=1.0 if show_purchase_check else 0.0,
797                )
798            if 'price_widget' in b_info:
799                ba.textwidget(
800                    edit=b_info['price_widget'],
801                    text=price_text,
802                    color=price_color,
803                )
804            if 'price_widget_left' in b_info:
805                ba.textwidget(
806                    edit=b_info['price_widget_left'], text=price_text_left
807                )
808            if 'price_widget_right' in b_info:
809                ba.textwidget(
810                    edit=b_info['price_widget_right'], text=price_text_right
811                )
812            if 'price_slash_widget' in b_info:
813                ba.imagewidget(
814                    edit=b_info['price_slash_widget'], opacity=sale_opacity
815                )
816            if 'sale_bg_widget' in b_info:
817                ba.imagewidget(
818                    edit=b_info['sale_bg_widget'], opacity=sale_opacity
819                )
820            if 'sale_title_widget' in b_info:
821                ba.textwidget(
822                    edit=b_info['sale_title_widget'], text=sale_title_text
823                )
824            if 'sale_time_widget' in b_info:
825                ba.textwidget(
826                    edit=b_info['sale_time_widget'], text=sale_time_text
827                )
828            if 'button' in b_info:
829                ba.buttonwidget(
830                    edit=b_info['button'], color=color, on_activate_call=call
831                )
832            if 'extra_backings' in b_info:
833                for bck in b_info['extra_backings']:
834                    ba.imagewidget(
835                        edit=bck, color=color, opacity=extra_image_opacity
836                    )
837            if 'extra_images' in b_info:
838                for img in b_info['extra_images']:
839                    ba.imagewidget(edit=img, opacity=extra_image_opacity)
840            if 'extra_texts' in b_info:
841                for etxt in b_info['extra_texts']:
842                    ba.textwidget(edit=etxt, color=description_color)
843            if 'extra_texts_2' in b_info:
844                for etxt in b_info['extra_texts_2']:
845                    ba.textwidget(edit=etxt, color=description_color2)
846            if 'descriptionText' in b_info:
847                ba.textwidget(
848                    edit=b_info['descriptionText'], color=description_color
849                )

Update our buttons.

Inherited Members
ba.ui.Window
get_root_widget
class StoreBrowserWindow.TabID(enum.Enum):
31    class TabID(Enum):
32        """Our available tab types."""
33
34        EXTRAS = 'extras'
35        MAPS = 'maps'
36        MINIGAMES = 'minigames'
37        CHARACTERS = 'characters'
38        ICONS = 'icons'

Our available tab types.

EXTRAS = <TabID.EXTRAS: 'extras'>
MAPS = <TabID.MAPS: 'maps'>
MINIGAMES = <TabID.MINIGAMES: 'minigames'>
CHARACTERS = <TabID.CHARACTERS: 'characters'>
ICONS = <TabID.ICONS: 'icons'>
Inherited Members
enum.Enum
name
value