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

Attempt to purchase the provided item.

def update_buttons(self) -> None:
534    def update_buttons(self) -> None:
535        """Update our buttons."""
536        # pylint: disable=too-many-statements
537        # pylint: disable=too-many-branches
538        # pylint: disable=too-many-locals
539        from ba.internal import get_available_sale_time
540        from ba import SpecialChar
541        if not self._root_widget:
542            return
543        import datetime
544        sales_raw = _ba.get_v1_account_misc_read_val('sales', {})
545        sales = {}
546        try:
547            # Look at the current set of sales; filter any with time remaining.
548            for sale_item, sale_info in list(sales_raw.items()):
549                to_end = (datetime.datetime.utcfromtimestamp(sale_info['e']) -
550                          datetime.datetime.utcnow()).total_seconds()
551                if to_end > 0:
552                    sales[sale_item] = {
553                        'to_end': to_end,
554                        'original_price': sale_info['op']
555                    }
556        except Exception:
557            ba.print_exception('Error parsing sales.')
558
559        assert self.button_infos is not None
560        for b_type, b_info in self.button_infos.items():
561
562            if b_type in ['upgrades.pro', 'pro']:
563                purchased = _ba.app.accounts_v1.have_pro()
564            else:
565                purchased = _ba.get_purchased(b_type)
566
567            sale_opacity = 0.0
568            sale_title_text: str | ba.Lstr = ''
569            sale_time_text: str | ba.Lstr = ''
570
571            if purchased:
572                title_color = (0.8, 0.7, 0.9, 1.0)
573                color = (0.63, 0.55, 0.78)
574                extra_image_opacity = 0.5
575                call = ba.WeakCall(self._print_already_own, b_info['name'])
576                price_text = ''
577                price_text_left = ''
578                price_text_right = ''
579                show_purchase_check = True
580                description_color: Sequence[float] = (0.4, 1.0, 0.4, 0.4)
581                description_color2: Sequence[float] = (0.0, 0.0, 0.0, 0.0)
582                price_color = (0.5, 1, 0.5, 0.3)
583            else:
584                title_color = (0.7, 0.9, 0.7, 1.0)
585                color = (0.4, 0.8, 0.1)
586                extra_image_opacity = 1.0
587                call = b_info['call'] if 'call' in b_info else None
588                if b_type in ['upgrades.pro', 'pro']:
589                    sale_time = get_available_sale_time('extras')
590                    if sale_time is not None:
591                        priceraw = _ba.get_price('pro')
592                        price_text_left = (priceraw
593                                           if priceraw is not None else '?')
594                        priceraw = _ba.get_price('pro_sale')
595                        price_text_right = (priceraw
596                                            if priceraw is not None else '?')
597                        sale_opacity = 1.0
598                        price_text = ''
599                        sale_title_text = ba.Lstr(resource='store.saleText')
600                        sale_time_text = ba.timestring(
601                            sale_time,
602                            centi=False,
603                            timeformat=ba.TimeFormat.MILLISECONDS)
604                    else:
605                        priceraw = _ba.get_price('pro')
606                        price_text = priceraw if priceraw is not None else '?'
607                        price_text_left = ''
608                        price_text_right = ''
609                else:
610                    price = _ba.get_v1_account_misc_read_val(
611                        'price.' + b_type, 0)
612
613                    # Color the button differently if we cant afford this.
614                    if _ba.get_v1_account_state() == 'signed_in':
615                        if _ba.get_v1_account_ticket_count() < price:
616                            color = (0.6, 0.61, 0.6)
617                    price_text = ba.charstr(ba.SpecialChar.TICKET) + str(
618                        _ba.get_v1_account_misc_read_val(
619                            'price.' + b_type, '?'))
620                    price_text_left = ''
621                    price_text_right = ''
622
623                    # TESTING:
624                    if b_type in sales:
625                        sale_opacity = 1.0
626                        price_text_left = ba.charstr(SpecialChar.TICKET) + str(
627                            sales[b_type]['original_price'])
628                        price_text_right = price_text
629                        price_text = ''
630                        sale_title_text = ba.Lstr(resource='store.saleText')
631                        sale_time_text = ba.timestring(
632                            int(sales[b_type]['to_end'] * 1000),
633                            centi=False,
634                            timeformat=ba.TimeFormat.MILLISECONDS)
635
636                description_color = (0.5, 1.0, 0.5)
637                description_color2 = (0.3, 1.0, 1.0)
638                price_color = (0.2, 1, 0.2, 1.0)
639                show_purchase_check = False
640
641            if 'title_text' in b_info:
642                ba.textwidget(edit=b_info['title_text'], color=title_color)
643            if 'purchase_check' in b_info:
644                ba.imagewidget(edit=b_info['purchase_check'],
645                               opacity=1.0 if show_purchase_check else 0.0)
646            if 'price_widget' in b_info:
647                ba.textwidget(edit=b_info['price_widget'],
648                              text=price_text,
649                              color=price_color)
650            if 'price_widget_left' in b_info:
651                ba.textwidget(edit=b_info['price_widget_left'],
652                              text=price_text_left)
653            if 'price_widget_right' in b_info:
654                ba.textwidget(edit=b_info['price_widget_right'],
655                              text=price_text_right)
656            if 'price_slash_widget' in b_info:
657                ba.imagewidget(edit=b_info['price_slash_widget'],
658                               opacity=sale_opacity)
659            if 'sale_bg_widget' in b_info:
660                ba.imagewidget(edit=b_info['sale_bg_widget'],
661                               opacity=sale_opacity)
662            if 'sale_title_widget' in b_info:
663                ba.textwidget(edit=b_info['sale_title_widget'],
664                              text=sale_title_text)
665            if 'sale_time_widget' in b_info:
666                ba.textwidget(edit=b_info['sale_time_widget'],
667                              text=sale_time_text)
668            if 'button' in b_info:
669                ba.buttonwidget(edit=b_info['button'],
670                                color=color,
671                                on_activate_call=call)
672            if 'extra_backings' in b_info:
673                for bck in b_info['extra_backings']:
674                    ba.imagewidget(edit=bck,
675                                   color=color,
676                                   opacity=extra_image_opacity)
677            if 'extra_images' in b_info:
678                for img in b_info['extra_images']:
679                    ba.imagewidget(edit=img, opacity=extra_image_opacity)
680            if 'extra_texts' in b_info:
681                for etxt in b_info['extra_texts']:
682                    ba.textwidget(edit=etxt, color=description_color)
683            if 'extra_texts_2' in b_info:
684                for etxt in b_info['extra_texts_2']:
685                    ba.textwidget(edit=etxt, color=description_color2)
686            if 'descriptionText' in b_info:
687                ba.textwidget(edit=b_info['descriptionText'],
688                              color=description_color)

Update our buttons.

Inherited Members
ba.ui.Window
get_root_widget
class StoreBrowserWindow.TabID(enum.Enum):
24    class TabID(Enum):
25        """Our available tab types."""
26        EXTRAS = 'extras'
27        MAPS = 'maps'
28        MINIGAMES = 'minigames'
29        CHARACTERS = 'characters'
30        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