bauiv1lib.chest

Provides chest related ui.

   1# Released under the MIT License. See LICENSE for details.
   2#
   3# pylint: disable=too-many-lines
   4"""Provides chest related ui."""
   5
   6from __future__ import annotations
   7
   8import math
   9import random
  10from typing import override, TYPE_CHECKING
  11
  12from efro.util import strict_partial
  13import bacommon.bs
  14import bauiv1 as bui
  15
  16if TYPE_CHECKING:
  17    import datetime
  18
  19    import baclassic
  20
  21_g_open_voices: list[tuple[float, str, float]] = []
  22
  23
  24class ChestWindow(bui.MainWindow):
  25    """Allows viewing and performing operations on a chest."""
  26
  27    def __init__(
  28        self,
  29        index: int,
  30        transition: str | None = 'in_right',
  31        origin_widget: bui.Widget | None = None,
  32    ):
  33        self._index = index
  34
  35        assert bui.app.classic is not None
  36        uiscale = bui.app.ui_v1.uiscale
  37        self._width = 1200 if uiscale is bui.UIScale.SMALL else 650
  38        self._height = 800 if uiscale is bui.UIScale.SMALL else 450
  39        self._action_in_flight = False
  40        self._open_now_button: bui.Widget | None = None
  41        self._open_now_spinner: bui.Widget | None = None
  42        self._open_now_texts: list[bui.Widget] = []
  43        self._open_now_images: list[bui.Widget] = []
  44        self._watch_ad_button: bui.Widget | None = None
  45        self._time_string_timer: bui.AppTimer | None = None
  46        self._time_string_text: bui.Widget | None = None
  47        self._prizesets: list[bacommon.bs.ChestInfoResponse.Chest.PrizeSet] = []
  48        self._prizeindex = -1
  49        self._prizesettxts: dict[int, list[bui.Widget]] = {}
  50        self._prizesetimgs: dict[int, list[bui.Widget]] = {}
  51        self._chestdisplayinfo: baclassic.ChestAppearanceDisplayInfo | None = (
  52            None
  53        )
  54
  55        self._chest_action_ui_pause: bui.RootUIUpdatePause | None = None
  56
  57        # The set of widgets we keep when doing a clear.
  58        self._core_widgets: list[bui.Widget] = []
  59
  60        # Do some fancy math to fill all available screen area up to the
  61        # size of our backing container. This lets us fit to the exact
  62        # screen shape at small ui scale.
  63        screensize = bui.get_virtual_screen_size()
  64        scale = (
  65            1.8
  66            if uiscale is bui.UIScale.SMALL
  67            else 1.1 if uiscale is bui.UIScale.MEDIUM else 0.9
  68        )
  69        # Calc screen size in our local container space and clamp to a
  70        # bit smaller than our container size.
  71        target_height = min(self._height - 120, screensize[1] / scale)
  72
  73        # To get top/left coords, go to the center of our window and
  74        # offset by half the width/height of our target area.
  75        self._yoffstop = 0.5 * self._height + 0.5 * target_height + 18
  76
  77        # Offset for stuff we want centered.
  78        self._yoffs = 0.5 * self._height + (
  79            220 if uiscale is bui.UIScale.SMALL else 190
  80        )
  81
  82        self._chest_yoffs = self._yoffs - 223
  83
  84        super().__init__(
  85            root_widget=bui.containerwidget(
  86                size=(self._width, self._height),
  87                toolbar_visibility='menu_full',
  88                scale=scale,
  89            ),
  90            transition=transition,
  91            origin_widget=origin_widget,
  92            # We're affected by screen size only at small ui-scale.
  93            refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL,
  94        )
  95
  96        self._title_text = bui.textwidget(
  97            parent=self._root_widget,
  98            position=(
  99                self._width * 0.5,
 100                self._yoffstop - (36 if uiscale is bui.UIScale.SMALL else 10),
 101            ),
 102            size=(0, 0),
 103            text=bui.Lstr(
 104                resource='chests.slotText',
 105                subs=[('${NUM}', str(index + 1))],
 106            ),
 107            color=bui.app.ui_v1.title_color,
 108            maxwidth=110.0 if uiscale is bui.UIScale.SMALL else 200,
 109            scale=0.9 if uiscale is bui.UIScale.SMALL else 1.1,
 110            h_align='center',
 111            v_align='center',
 112        )
 113        self._core_widgets.append(self._title_text)
 114
 115        if uiscale is bui.UIScale.SMALL:
 116            bui.containerwidget(
 117                edit=self._root_widget, on_cancel_call=self.main_window_back
 118            )
 119        else:
 120            btn = bui.buttonwidget(
 121                parent=self._root_widget,
 122                position=(50, self._yoffs - 44),
 123                size=(60, 55),
 124                scale=0.8,
 125                label=bui.charstr(bui.SpecialChar.BACK),
 126                button_type='backSmall',
 127                extra_touch_border_scale=2.0,
 128                autoselect=True,
 129                on_activate_call=self.main_window_back,
 130            )
 131            bui.containerwidget(edit=self._root_widget, cancel_button=btn)
 132            self._core_widgets.append(btn)
 133
 134        # Note: Don't need to explicitly clean this up. Just not adding
 135        # it to core_widgets so it will go away on next reset.
 136        self._loadingspinner = bui.spinnerwidget(
 137            parent=self._root_widget,
 138            position=(self._width * 0.5, self._height * 0.5),
 139            size=48,
 140            style='bomb',
 141        )
 142
 143        self._infotext = bui.textwidget(
 144            parent=self._root_widget,
 145            position=(self._width * 0.5, self._yoffs - 200),
 146            size=(0, 0),
 147            text='',
 148            maxwidth=700,
 149            scale=0.8,
 150            color=(0.6, 0.5, 0.6),
 151            h_align='center',
 152            v_align='center',
 153        )
 154        self._core_widgets.append(self._infotext)
 155
 156        plus = bui.app.plus
 157        if plus is None:
 158            self._error('Plus feature-set is not present.')
 159            return
 160
 161        if plus.accounts.primary is None:
 162            self._error(bui.Lstr(resource='notSignedInText'))
 163            return
 164
 165        # Start by showing info/options for our target chest. Note that
 166        # we always ask the server for these values even though we may
 167        # have them through our appmode subscription which updates the
 168        # chest UI. This is because the wait_for_connectivity()
 169        # mechanism will often bring our window up a split second before
 170        # the chest subscription receives its first values which would
 171        # lead us to incorrectly think there is no chest there. If we
 172        # want to optimize this in the future we could perhaps use local
 173        # values only if there is a chest present in them.
 174        assert not self._action_in_flight
 175        self._action_in_flight = True
 176        with plus.accounts.primary:
 177            plus.cloud.send_message_cb(
 178                bacommon.bs.ChestInfoMessage(chest_id=str(self._index)),
 179                on_response=bui.WeakCall(self._on_chest_info_response),
 180            )
 181
 182    @override
 183    def get_main_window_state(self) -> bui.MainWindowState:
 184        # Support recreating our window for back/refresh purposes.
 185        cls = type(self)
 186
 187        # Pull anything we need from self out here; if we do it in the
 188        # lambda we keep self alive which is bad.
 189        index = self._index
 190
 191        return bui.BasicMainWindowState(
 192            create_call=lambda transition, origin_widget: cls(
 193                index=index, transition=transition, origin_widget=origin_widget
 194            )
 195        )
 196
 197    def _update_time_display(self, unlock_time: datetime.datetime) -> None:
 198        # Once text disappears, kill our timer.
 199        if not self._time_string_text:
 200            self._time_string_timer = None
 201            return
 202        now = bui.utc_now_cloud()
 203        secs_till_open = max(0.0, (unlock_time - now).total_seconds())
 204        tstr = (
 205            bui.timestring(secs_till_open, centi=False)
 206            if secs_till_open > 0
 207            else ''
 208        )
 209        bui.textwidget(edit=self._time_string_text, text=tstr)
 210
 211    def _on_chest_info_response(
 212        self, response: bacommon.bs.ChestInfoResponse | Exception
 213    ) -> None:
 214        assert self._action_in_flight  # Should be us.
 215        self._action_in_flight = False
 216
 217        if isinstance(response, Exception):
 218            self._error(
 219                bui.Lstr(resource='internal.unableToCompleteTryAgainText'),
 220                minor=True,
 221            )
 222            return
 223
 224        if response.chest is None:
 225            self._show_about_chest_slots()
 226            return
 227
 228        assert response.user_tokens is not None
 229        self._show_chest_actions(response.user_tokens, response.chest)
 230
 231    def _on_chest_action_response(
 232        self, response: bacommon.bs.ChestActionResponse | Exception
 233    ) -> None:
 234        assert self._action_in_flight  # Should be us.
 235        self._action_in_flight = False
 236
 237        # Allow the root ui to resume its normal automatic value display
 238        # as soon as any animations we kick off here complete.
 239        self._chest_action_ui_pause = None
 240
 241        # Communication/local error:
 242        if isinstance(response, Exception):
 243            self._error(
 244                bui.Lstr(resource='internal.unableToCompleteTryAgainText'),
 245                minor=True,
 246            )
 247            return
 248
 249        # Server-side error:
 250        if response.error is not None:
 251            self._error(bui.Lstr(translate=('serverResponses', response.error)))
 252            return
 253
 254        toffs = 0.0
 255        # If there's contents listed in the response, show them.
 256        if response.contents is not None:
 257            toffs = self._show_chest_contents(response)
 258        else:
 259            # Otherwise we're done here; just close out our UI.
 260            self.main_window_back()
 261
 262        # Lastly, run any bundled effects.
 263        assert bui.app.classic is not None
 264        bui.app.classic.run_bs_client_effects(response.effects, delay=toffs)
 265
 266    def _show_chest_actions(
 267        self, user_tokens: int, chest: bacommon.bs.ChestInfoResponse.Chest
 268    ) -> None:
 269        """Show state for our chest."""
 270        # pylint: disable=too-many-locals
 271        # pylint: disable=cyclic-import
 272        from baclassic import (
 273            ClassicAppMode,
 274            CHEST_APPEARANCE_DISPLAY_INFOS,
 275            CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT,
 276        )
 277
 278        plus = bui.app.plus
 279        assert plus is not None
 280
 281        # We expect to be run under classic app mode.
 282        mode = bui.app.mode
 283        if not isinstance(mode, ClassicAppMode):
 284            self._error('Classic app mode not active.')
 285            return
 286
 287        self._reset()
 288
 289        self._chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get(
 290            chest.appearance, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT
 291        )
 292
 293        bui.textwidget(
 294            edit=self._title_text,
 295            text=bui.Lstr(
 296                translate=('displayItemNames', chest.appearance.pretty_name)
 297            ),
 298        )
 299
 300        imgsize = 145
 301        bui.imagewidget(
 302            parent=self._root_widget,
 303            position=(
 304                self._width * 0.5 - imgsize * 0.5,
 305                # self._height - 223 + self._yoffs,
 306                self._chest_yoffs,
 307            ),
 308            color=self._chestdisplayinfo.color,
 309            size=(imgsize, imgsize),
 310            texture=bui.gettexture(self._chestdisplayinfo.texclosed),
 311            tint_texture=bui.gettexture(self._chestdisplayinfo.texclosedtint),
 312            tint_color=self._chestdisplayinfo.tint,
 313            tint2_color=self._chestdisplayinfo.tint2,
 314        )
 315
 316        # Store the prize-sets so we can display odds/etc. Sort them
 317        # with largest weights first.
 318        self._prizesets = sorted(
 319            chest.prizesets, key=lambda s: s.weight, reverse=True
 320        )
 321
 322        if chest.unlock_tokens > 0:
 323            lsize = 30
 324            bui.imagewidget(
 325                parent=self._root_widget,
 326                position=(
 327                    self._width * 0.5 - imgsize * 0.4 - lsize * 0.5,
 328                    # self._height - 223 + 27.0 + self._yoffs,
 329                    self._chest_yoffs + 27.0,
 330                ),
 331                size=(lsize, lsize),
 332                texture=bui.gettexture('lock'),
 333            )
 334
 335        # Time string.
 336        if chest.unlock_tokens != 0:
 337            self._time_string_text = bui.textwidget(
 338                parent=self._root_widget,
 339                position=(
 340                    self._width * 0.5,
 341                    # self._height - 85 + self._yoffs
 342                    self._yoffs - 85,
 343                ),
 344                size=(0, 0),
 345                text='',
 346                maxwidth=700,
 347                scale=0.6,
 348                color=(0.6, 1.0, 0.6),
 349                h_align='center',
 350                v_align='center',
 351            )
 352            self._update_time_display(chest.unlock_time)
 353            self._time_string_timer = bui.AppTimer(
 354                1.0,
 355                repeat=True,
 356                call=bui.WeakCall(self._update_time_display, chest.unlock_time),
 357            )
 358
 359        # Allow watching an ad IF the server tells us we can AND we have
 360        # an ad ready to show.
 361        show_ad_button = (
 362            chest.unlock_tokens > 0
 363            and chest.ad_allow
 364            and plus.have_incentivized_ad()
 365        )
 366
 367        bwidth = 130
 368        bheight = 90
 369        bposy = -330 if chest.unlock_tokens == 0 else -340
 370        hspace = 20
 371        boffsx = (hspace * -0.5 - bwidth * 0.5) if show_ad_button else 0.0
 372
 373        self._open_now_button = bui.buttonwidget(
 374            parent=self._root_widget,
 375            position=(
 376                self._width * 0.5 - bwidth * 0.5 + boffsx,
 377                self._yoffs + bposy,
 378            ),
 379            size=(bwidth, bheight),
 380            label='',
 381            button_type='square',
 382            autoselect=True,
 383            on_activate_call=bui.WeakCall(
 384                self._open_press, user_tokens, chest.unlock_tokens
 385            ),
 386            enable_sound=False,
 387        )
 388        self._open_now_images = []
 389        self._open_now_texts = []
 390
 391        iconsize = 50
 392        if chest.unlock_tokens == 0:
 393            self._open_now_texts.append(
 394                bui.textwidget(
 395                    parent=self._root_widget,
 396                    text=bui.Lstr(resource='openText'),
 397                    position=(
 398                        self._width * 0.5 + boffsx,
 399                        self._yoffs + bposy + bheight * 0.5,
 400                    ),
 401                    color=(0, 1, 0),
 402                    draw_controller=self._open_now_button,
 403                    scale=0.7,
 404                    maxwidth=bwidth * 0.8,
 405                    size=(0, 0),
 406                    h_align='center',
 407                    v_align='center',
 408                )
 409            )
 410        else:
 411            self._open_now_texts.append(
 412                bui.textwidget(
 413                    parent=self._root_widget,
 414                    text=bui.Lstr(resource='openNowText'),
 415                    position=(
 416                        self._width * 0.5 + boffsx,
 417                        self._yoffs + bposy + bheight * 1.15,
 418                    ),
 419                    maxwidth=bwidth * 0.8,
 420                    scale=0.7,
 421                    color=(0.7, 1, 0.7),
 422                    size=(0, 0),
 423                    h_align='center',
 424                    v_align='center',
 425                )
 426            )
 427            self._open_now_images.append(
 428                bui.imagewidget(
 429                    parent=self._root_widget,
 430                    size=(iconsize, iconsize),
 431                    position=(
 432                        self._width * 0.5 - iconsize * 0.5 + boffsx,
 433                        self._yoffs + bposy + bheight * 0.35,
 434                    ),
 435                    draw_controller=self._open_now_button,
 436                    texture=bui.gettexture('coin'),
 437                )
 438            )
 439            self._open_now_texts.append(
 440                bui.textwidget(
 441                    parent=self._root_widget,
 442                    text=bui.Lstr(
 443                        resource='tokens.numTokensText',
 444                        subs=[('${COUNT}', str(chest.unlock_tokens))],
 445                    ),
 446                    position=(
 447                        self._width * 0.5 + boffsx,
 448                        self._yoffs + bposy + bheight * 0.25,
 449                    ),
 450                    scale=0.65,
 451                    color=(0, 1, 0),
 452                    draw_controller=self._open_now_button,
 453                    maxwidth=bwidth * 0.8,
 454                    size=(0, 0),
 455                    h_align='center',
 456                    v_align='center',
 457                )
 458            )
 459        self._open_now_spinner = bui.spinnerwidget(
 460            parent=self._root_widget,
 461            position=(
 462                self._width * 0.5 + boffsx,
 463                self._yoffs + bposy + 0.5 * bheight,
 464            ),
 465            visible=False,
 466        )
 467
 468        if show_ad_button:
 469            bui.textwidget(
 470                parent=self._root_widget,
 471                text=bui.Lstr(resource='chests.reduceWaitText'),
 472                position=(
 473                    self._width * 0.5 + hspace * 0.5 + bwidth * 0.5,
 474                    self._yoffs + bposy + bheight * 1.15,
 475                ),
 476                maxwidth=bwidth * 0.8,
 477                scale=0.7,
 478                color=(0.7, 1, 0.7),
 479                size=(0, 0),
 480                h_align='center',
 481                v_align='center',
 482            )
 483            self._watch_ad_button = bui.buttonwidget(
 484                parent=self._root_widget,
 485                position=(
 486                    self._width * 0.5 + hspace * 0.5,
 487                    self._yoffs + bposy,
 488                ),
 489                size=(bwidth, bheight),
 490                label='',
 491                button_type='square',
 492                autoselect=True,
 493                on_activate_call=bui.WeakCall(self._watch_ad_press),
 494                enable_sound=False,
 495            )
 496            bui.imagewidget(
 497                parent=self._root_widget,
 498                size=(iconsize, iconsize),
 499                position=(
 500                    self._width * 0.5
 501                    + hspace * 0.5
 502                    + bwidth * 0.5
 503                    - iconsize * 0.5,
 504                    self._yoffs + bposy + bheight * 0.35,
 505                ),
 506                draw_controller=self._watch_ad_button,
 507                color=(1.5, 1.0, 2.0),
 508                texture=bui.gettexture('tv'),
 509            )
 510            # Note to self: AdMob requires rewarded ad usage
 511            # specifically says 'Ad' in it.
 512            bui.textwidget(
 513                parent=self._root_widget,
 514                text=bui.Lstr(resource='watchAnAdText'),
 515                position=(
 516                    self._width * 0.5 + hspace * 0.5 + bwidth * 0.5,
 517                    self._yoffs + bposy + bheight * 0.25,
 518                ),
 519                scale=0.65,
 520                color=(0, 1, 0),
 521                draw_controller=self._watch_ad_button,
 522                maxwidth=bwidth * 0.8,
 523                size=(0, 0),
 524                h_align='center',
 525                v_align='center',
 526            )
 527
 528        self._show_odds(initial_highlighted_row=-1)
 529
 530    def _highlight_odds_row(self, row: int, extra: bool = False) -> None:
 531
 532        for rindex, imgs in self._prizesetimgs.items():
 533            opacity = (
 534                (0.9 if extra else 0.75)
 535                if rindex == row
 536                else (0.4 if extra else 0.5)
 537            )
 538            for img in imgs:
 539                if img:
 540                    bui.imagewidget(edit=img, opacity=opacity)
 541
 542        for rindex, txts in self._prizesettxts.items():
 543            opacity = (
 544                (0.9 if extra else 0.75)
 545                if rindex == row
 546                else (0.4 if extra else 0.5)
 547            )
 548            for txt in txts:
 549                if txt:
 550                    bui.textwidget(edit=txt, color=(0.7, 0.65, 1, opacity))
 551
 552    def _show_odds(
 553        self,
 554        *,
 555        initial_highlighted_row: int,
 556        initial_highlighted_extra: bool = False,
 557    ) -> None:
 558        # pylint: disable=too-many-locals
 559        xoffs = 110
 560
 561        totalweight = max(0.001, sum(t.weight for t in self._prizesets))
 562
 563        rowheight = 25
 564        totalheight = (len(self._prizesets) + 1) * rowheight
 565        x = self._width * 0.5 + xoffs
 566        y = self._yoffs - 150.0 + totalheight * 0.5
 567
 568        # Title.
 569        bui.textwidget(
 570            parent=self._root_widget,
 571            text=bui.Lstr(resource='chests.prizeOddsText'),
 572            color=(0.7, 0.65, 1, 0.5),
 573            flatness=1.0,
 574            shadow=1.0,
 575            position=(x, y),
 576            scale=0.55,
 577            size=(0, 0),
 578            h_align='left',
 579            v_align='center',
 580        )
 581        y -= 5.0
 582
 583        prizesettxts: list[bui.Widget]
 584        prizesetimgs: list[bui.Widget]
 585
 586        def _mkicon(img: str) -> None:
 587            iconsize = 20.0
 588            nonlocal x
 589            nonlocal prizesetimgs
 590            prizesetimgs.append(
 591                bui.imagewidget(
 592                    parent=self._root_widget,
 593                    size=(iconsize, iconsize),
 594                    position=(x, y - iconsize * 0.5),
 595                    texture=bui.gettexture(img),
 596                    opacity=0.4,
 597                )
 598            )
 599            x += iconsize
 600
 601        def _mktxt(txt: str, advance: bool = True) -> None:
 602            tscale = 0.45
 603            nonlocal x
 604            nonlocal prizesettxts
 605            prizesettxts.append(
 606                bui.textwidget(
 607                    parent=self._root_widget,
 608                    text=txt,
 609                    flatness=1.0,
 610                    shadow=1.0,
 611                    position=(x, y),
 612                    scale=tscale,
 613                    size=(0, 0),
 614                    h_align='left',
 615                    v_align='center',
 616                )
 617            )
 618            if advance:
 619                x += (bui.get_string_width(txt, suppress_warning=True)) * tscale
 620
 621        self._prizesettxts = {}
 622        self._prizesetimgs = {}
 623
 624        for i, p in enumerate(self._prizesets):
 625            prizesettxts = self._prizesettxts.setdefault(i, [])
 626            prizesetimgs = self._prizesetimgs.setdefault(i, [])
 627            x = self._width * 0.5 + xoffs
 628            y -= rowheight
 629            percent = 100.0 * p.weight / totalweight
 630
 631            # Show decimals only if we get very small percentages (looks
 632            # better than rounding as '0%').
 633            percenttxt = (
 634                f'{percent:.2f}%:'
 635                if percent < 0.095
 636                else (
 637                    f'{percent:.1f}%:'
 638                    if percent < 0.95
 639                    else f'{round(percent)}%:'
 640                )
 641            )
 642
 643            # We advance manually here to keep values lined up
 644            # (otherwise single digit percent rows don't line up with
 645            # double digit ones).
 646            _mktxt(percenttxt, advance=False)
 647            x += 35.0
 648
 649            for item in p.contents:
 650                x += 5.0
 651                if isinstance(item.item, bacommon.bs.TicketsDisplayItem):
 652                    _mktxt(str(item.item.count))
 653                    _mkicon('tickets')
 654                elif isinstance(item.item, bacommon.bs.TokensDisplayItem):
 655                    _mktxt(str(item.item.count))
 656                    _mkicon('coin')
 657                else:
 658                    # For other cases just fall back on text desc.
 659                    #
 660                    # Translate the wrapper description and apply any subs.
 661                    descfin = bui.Lstr(
 662                        translate=('serverResponses', item.description)
 663                    ).evaluate()
 664                    subs = (
 665                        []
 666                        if item.description_subs is None
 667                        else item.description_subs
 668                    )
 669                    assert len(subs) % 2 == 0  # Should always be even.
 670                    for j in range(0, len(subs) - 1, 2):
 671                        descfin = descfin.replace(subs[j], subs[j + 1])
 672                    _mktxt(descfin)
 673        self._highlight_odds_row(
 674            initial_highlighted_row, extra=initial_highlighted_extra
 675        )
 676
 677    def _open_press(self, user_tokens: int, token_payment: int) -> None:
 678        from bauiv1lib.gettokens import show_get_tokens_prompt
 679
 680        bui.getsound('click01').play()
 681
 682        # Allow only one in-flight action at once.
 683        if self._action_in_flight:
 684            bui.screenmessage(
 685                bui.Lstr(resource='pleaseWaitText'), color=(1, 0, 0)
 686            )
 687            bui.getsound('error').play()
 688            return
 689
 690        plus = bui.app.plus
 691        assert plus is not None
 692
 693        if plus.accounts.primary is None:
 694            self._error(bui.Lstr(resource='notSignedInText'))
 695            return
 696
 697        # Offer to purchase tokens if they don't have enough.
 698        if user_tokens < token_payment:
 699            # Hack: We disable normal swish for the open button and it
 700            # seems weird without a swish here, so explicitly do one.
 701            bui.getsound('swish').play()
 702            show_get_tokens_prompt()
 703            return
 704
 705        self._action_in_flight = True
 706
 707        # Pause implicit root-ui updates at least until we're done with
 708        # this message, as we might want to explicitly animate stuff from
 709        # the results and don't want live values to jump the gun.
 710        self._chest_action_ui_pause = bui.RootUIUpdatePause()
 711
 712        with plus.accounts.primary:
 713            plus.cloud.send_message_cb(
 714                bacommon.bs.ChestActionMessage(
 715                    chest_id=str(self._index),
 716                    action=bacommon.bs.ChestActionMessage.Action.UNLOCK,
 717                    token_payment=token_payment,
 718                ),
 719                on_response=bui.WeakCall(self._on_chest_action_response),
 720            )
 721
 722        # Convey that something is in progress.
 723        if self._open_now_button:
 724            bui.spinnerwidget(edit=self._open_now_spinner, visible=True)
 725            for twidget in self._open_now_texts:
 726                bui.textwidget(edit=twidget, color=(1, 1, 1, 0.2))
 727            for iwidget in self._open_now_images:
 728                bui.imagewidget(edit=iwidget, opacity=0.2)
 729
 730    def _watch_ad_press(self) -> None:
 731
 732        bui.getsound('click01').play()
 733
 734        # Allow only one in-flight action at once.
 735        if self._action_in_flight:
 736            bui.screenmessage(
 737                bui.Lstr(resource='pleaseWaitText'), color=(1, 0, 0)
 738            )
 739            bui.getsound('error').play()
 740            return
 741
 742        assert bui.app.classic is not None
 743
 744        self._action_in_flight = True
 745        bui.app.classic.ads.show_ad_2(
 746            'reduce_chest_wait',
 747            on_completion_call=bui.WeakCall(self._watch_ad_complete),
 748        )
 749
 750        # Convey that something is in progress.
 751        if self._watch_ad_button:
 752            bui.buttonwidget(edit=self._watch_ad_button, color=(0.4, 0.4, 0.4))
 753
 754    def _watch_ad_complete(self, actually_showed: bool) -> None:
 755
 756        assert self._action_in_flight  # Should be ad view.
 757        self._action_in_flight = False
 758
 759        if not actually_showed:
 760            return
 761
 762        # Allow only one in-flight action at once.
 763        if self._action_in_flight:
 764            bui.screenmessage(
 765                bui.Lstr(resource='pleaseWaitText'), color=(1, 0, 0)
 766            )
 767            bui.getsound('error').play()
 768            return
 769
 770        plus = bui.app.plus
 771        assert plus is not None
 772
 773        if plus.accounts.primary is None:
 774            self._error(bui.Lstr(resource='notSignedInText'))
 775            return
 776
 777        self._action_in_flight = True
 778
 779        # Pause implicit root-ui updates at least until we're done with
 780        # this message, as we might want to explicitly animate stuff from
 781        # the results and don't want live values to jump the gun.
 782        self._chest_action_ui_pause = bui.RootUIUpdatePause()
 783
 784        with plus.accounts.primary:
 785            plus.cloud.send_message_cb(
 786                bacommon.bs.ChestActionMessage(
 787                    chest_id=str(self._index),
 788                    action=bacommon.bs.ChestActionMessage.Action.AD,
 789                    token_payment=0,
 790                ),
 791                on_response=bui.WeakCall(self._on_chest_action_response),
 792            )
 793
 794    def _reset(self) -> None:
 795        """Clear all non-permanent widgets and clear infotext."""
 796        for widget in self._root_widget.get_children():
 797            if widget not in self._core_widgets:
 798                widget.delete()
 799        bui.textwidget(edit=self._infotext, text='', color=(1, 1, 1))
 800
 801    def _error(self, msg: str | bui.Lstr, minor: bool = False) -> None:
 802        """Put ourself in an error state with a visible error message."""
 803        self._reset()
 804        bui.textwidget(
 805            edit=self._infotext,
 806            text=msg,
 807            color=(1, 0.5, 0.5) if minor else (1, 0, 0),
 808        )
 809
 810    def _show_about_chest_slots(self) -> None:
 811        # No-op if our ui is dead.
 812        if not self._root_widget:
 813            return
 814
 815        self._reset()
 816        bui.textwidget(
 817            edit=self._infotext,
 818            text=bui.Lstr(resource='chests.slotDescriptionText'),
 819            color=(1, 1, 1),
 820        )
 821
 822    def _show_chest_contents(
 823        self, response: bacommon.bs.ChestActionResponse
 824    ) -> float:
 825        # pylint: disable=too-many-locals
 826
 827        from baclassic import show_display_item
 828
 829        # No-op if our ui is dead.
 830        if not self._root_widget:
 831            return 0.0
 832
 833        assert response.contents is not None
 834
 835        # Insert test items for testing.
 836        if bool(False):
 837            response.contents += [
 838                bacommon.bs.DisplayItemWrapper.for_display_item(
 839                    bacommon.bs.TestDisplayItem()
 840                )
 841            ]
 842
 843        tincr = 0.4
 844        tendoffs = tincr * 4.0
 845        toffs = 0.0
 846
 847        bui.getsound('revUp').play(volume=2.0)
 848
 849        # Show nothing but the chest icon and animate it shaking.
 850        self._reset()
 851        imgsize = 145
 852        assert self._chestdisplayinfo is not None
 853        img = bui.imagewidget(
 854            parent=self._root_widget,
 855            color=self._chestdisplayinfo.color,
 856            texture=bui.gettexture(self._chestdisplayinfo.texclosed),
 857            tint_texture=bui.gettexture(self._chestdisplayinfo.texclosedtint),
 858            tint_color=self._chestdisplayinfo.tint,
 859            tint2_color=self._chestdisplayinfo.tint2,
 860        )
 861
 862        def _set_img(x: float, scale: float) -> None:
 863            if not img:
 864                return
 865            bui.imagewidget(
 866                edit=img,
 867                position=(
 868                    self._width * 0.5 - imgsize * scale * 0.5 + x,
 869                    self._yoffs - 223 + imgsize * 0.5 - imgsize * scale * 0.5,
 870                ),
 871                size=(imgsize * scale, imgsize * scale),
 872            )
 873
 874        # Set initial place.
 875        _set_img(0.0, 1.0)
 876
 877        sign = 1.0
 878        while toffs < tendoffs:
 879            toffs += 0.03 * random.uniform(0.5, 1.5)
 880            sign = -sign
 881            bui.apptimer(
 882                toffs,
 883                bui.Call(
 884                    _set_img,
 885                    x=(
 886                        20.0
 887                        * random.uniform(0.3, 1.0)
 888                        * math.pow(toffs / tendoffs, 2.0)
 889                        * sign
 890                    ),
 891                    scale=1.0 - 0.2 * math.pow(toffs / tendoffs, 2.0),
 892                ),
 893            )
 894
 895        xspacing = 100
 896        xoffs = -0.5 * (len(response.contents) - 1) * xspacing
 897        bui.apptimer(
 898            toffs - 0.2, lambda: bui.getsound('corkPop2').play(volume=4.0)
 899        )
 900        # Play a variety of voice sounds.
 901
 902        # We keep a global list of voice options which we randomly pull
 903        # from and refill when empty. This ensures everything gets
 904        # played somewhat frequently and minimizes annoying repeats.
 905        global _g_open_voices  # pylint: disable=global-statement
 906        if not _g_open_voices:
 907            _g_open_voices = [
 908                (0.3, 'woo3', 2.5),
 909                (0.1, 'gasp', 1.3),
 910                (0.2, 'woo2', 2.0),
 911                (0.2, 'wow', 2.0),
 912                (0.2, 'kronk2', 2.0),
 913                (0.2, 'mel03', 2.0),
 914                (0.2, 'aww', 2.0),
 915                (0.4, 'nice', 2.0),
 916                (0.3, 'yeah', 1.5),
 917                (0.2, 'woo', 1.0),
 918                (0.5, 'ooh', 0.8),
 919            ]
 920
 921        voicetimeoffs, voicename, volume = _g_open_voices.pop(
 922            random.randrange(len(_g_open_voices))
 923        )
 924        bui.apptimer(
 925            toffs + voicetimeoffs,
 926            lambda: bui.getsound(voicename).play(volume=volume),
 927        )
 928
 929        toffsopen = toffs
 930        bui.apptimer(toffs, bui.WeakCall(self._show_chest_opening))
 931        toffs += tincr * 1.0
 932        width = xspacing * 0.95
 933
 934        for item in response.contents:
 935            toffs += tincr
 936            bui.apptimer(
 937                toffs - 0.1, lambda: bui.getsound('cashRegister').play()
 938            )
 939            bui.apptimer(
 940                toffs,
 941                strict_partial(
 942                    show_display_item,
 943                    item,
 944                    self._root_widget,
 945                    pos=(
 946                        self._width * 0.5 + xoffs,
 947                        self._yoffs - 250.0,
 948                    ),
 949                    width=width,
 950                ),
 951            )
 952            xoffs += xspacing
 953        toffs += tincr
 954        bui.apptimer(toffs, bui.WeakCall(self._show_done_button))
 955
 956        self._show_odds(initial_highlighted_row=-1)
 957
 958        # Store this for later
 959        self._prizeindex = response.prizeindex
 960
 961        # The final result was already randomly selected on the server,
 962        # but we want to give the illusion of randomness here, so cycle
 963        # through highlighting our options and stop on the winner when
 964        # the chest opens. To do this, we start at the end at the prize
 965        # and work backwards setting timers.
 966        if self._prizesets:
 967            toffs2 = toffsopen - 0.01
 968            amt = 0.02
 969            i = self._prizeindex
 970            while toffs2 > 0.0:
 971                bui.apptimer(
 972                    toffs2,
 973                    bui.WeakCall(self._highlight_odds_row, i),
 974                )
 975                toffs2 -= amt
 976                amt *= 1.05 * random.uniform(0.9, 1.1)
 977                i = (i - 1) % len(self._prizesets)
 978
 979        # Let the caller know how long we'll take in case they want to
 980        # schedule stuff after.
 981        return toffs + tincr
 982
 983    def _show_chest_opening(self) -> None:
 984
 985        # No-op if our ui is dead.
 986        if not self._root_widget:
 987            return
 988
 989        self._reset()
 990        imgsize = 145
 991        bui.getsound('hiss').play()
 992        assert self._chestdisplayinfo is not None
 993        img = bui.imagewidget(
 994            parent=self._root_widget,
 995            color=self._chestdisplayinfo.color,
 996            texture=bui.gettexture(self._chestdisplayinfo.texopen),
 997            tint_texture=bui.gettexture(self._chestdisplayinfo.texopentint),
 998            tint_color=self._chestdisplayinfo.tint,
 999            tint2_color=self._chestdisplayinfo.tint2,
1000        )
1001        tincr = 0.8
1002        tendoffs = tincr * 2.0
1003        toffs = 0.0
1004
1005        def _set_img(x: float, scale: float) -> None:
1006            if not img:
1007                return
1008            bui.imagewidget(
1009                edit=img,
1010                position=(
1011                    self._width * 0.5 - imgsize * scale * 0.5 + x,
1012                    self._yoffs - 223 + imgsize * 0.5 - imgsize * scale * 0.5,
1013                ),
1014                size=(imgsize * scale, imgsize * scale),
1015            )
1016
1017        # Set initial place.
1018        _set_img(0.0, 1.0)
1019
1020        sign = 1.0
1021        while toffs < tendoffs:
1022            toffs += 0.03 * random.uniform(0.5, 1.5)
1023            sign = -sign
1024            # Note: we speed x along here (multing toffs) so position
1025            # comes to rest before scale.
1026            bui.apptimer(
1027                toffs,
1028                bui.Call(
1029                    _set_img,
1030                    x=(
1031                        1.0
1032                        * random.uniform(0.3, 1.0)
1033                        * (
1034                            1.0
1035                            - math.pow(min(1.0, 3.0 * toffs / tendoffs), 2.0)
1036                        )
1037                        * sign
1038                    ),
1039                    scale=1.0 - 0.1 * math.pow(toffs / tendoffs, 0.5),
1040                ),
1041            )
1042
1043        self._show_odds(
1044            initial_highlighted_row=self._prizeindex,
1045            initial_highlighted_extra=True,
1046        )
1047
1048    def _show_done_button(self) -> None:
1049        # No-op if our ui is dead.
1050        if not self._root_widget:
1051            return
1052
1053        bwidth = 200
1054        bheight = 60
1055
1056        btn = bui.buttonwidget(
1057            parent=self._root_widget,
1058            position=(
1059                self._width * 0.5 - bwidth * 0.5,
1060                self._yoffs - 350,
1061            ),
1062            size=(bwidth, bheight),
1063            label=bui.Lstr(resource='doneText'),
1064            autoselect=True,
1065            on_activate_call=self.main_window_back,
1066        )
1067        bui.containerwidget(edit=self._root_widget, start_button=btn)
1068
1069
1070# Slight hack: we define window different classes for our different
1071# chest slots so that the default UI behavior is to replace each other
1072# when different ones are pressed. If they are all the same window class
1073# then the default behavior for such presses is to toggle the existing
1074# one back off.
1075
1076
1077class ChestWindow0(ChestWindow):
1078    """Child class of ChestWindow for slighty hackish reasons."""
1079
1080
1081class ChestWindow1(ChestWindow):
1082    """Child class of ChestWindow for slighty hackish reasons."""
1083
1084
1085class ChestWindow2(ChestWindow):
1086    """Child class of ChestWindow for slighty hackish reasons."""
1087
1088
1089class ChestWindow3(ChestWindow):
1090    """Child class of ChestWindow for slighty hackish reasons."""
class ChestWindow(bauiv1._uitypes.MainWindow):
  25class ChestWindow(bui.MainWindow):
  26    """Allows viewing and performing operations on a chest."""
  27
  28    def __init__(
  29        self,
  30        index: int,
  31        transition: str | None = 'in_right',
  32        origin_widget: bui.Widget | None = None,
  33    ):
  34        self._index = index
  35
  36        assert bui.app.classic is not None
  37        uiscale = bui.app.ui_v1.uiscale
  38        self._width = 1200 if uiscale is bui.UIScale.SMALL else 650
  39        self._height = 800 if uiscale is bui.UIScale.SMALL else 450
  40        self._action_in_flight = False
  41        self._open_now_button: bui.Widget | None = None
  42        self._open_now_spinner: bui.Widget | None = None
  43        self._open_now_texts: list[bui.Widget] = []
  44        self._open_now_images: list[bui.Widget] = []
  45        self._watch_ad_button: bui.Widget | None = None
  46        self._time_string_timer: bui.AppTimer | None = None
  47        self._time_string_text: bui.Widget | None = None
  48        self._prizesets: list[bacommon.bs.ChestInfoResponse.Chest.PrizeSet] = []
  49        self._prizeindex = -1
  50        self._prizesettxts: dict[int, list[bui.Widget]] = {}
  51        self._prizesetimgs: dict[int, list[bui.Widget]] = {}
  52        self._chestdisplayinfo: baclassic.ChestAppearanceDisplayInfo | None = (
  53            None
  54        )
  55
  56        self._chest_action_ui_pause: bui.RootUIUpdatePause | None = None
  57
  58        # The set of widgets we keep when doing a clear.
  59        self._core_widgets: list[bui.Widget] = []
  60
  61        # Do some fancy math to fill all available screen area up to the
  62        # size of our backing container. This lets us fit to the exact
  63        # screen shape at small ui scale.
  64        screensize = bui.get_virtual_screen_size()
  65        scale = (
  66            1.8
  67            if uiscale is bui.UIScale.SMALL
  68            else 1.1 if uiscale is bui.UIScale.MEDIUM else 0.9
  69        )
  70        # Calc screen size in our local container space and clamp to a
  71        # bit smaller than our container size.
  72        target_height = min(self._height - 120, screensize[1] / scale)
  73
  74        # To get top/left coords, go to the center of our window and
  75        # offset by half the width/height of our target area.
  76        self._yoffstop = 0.5 * self._height + 0.5 * target_height + 18
  77
  78        # Offset for stuff we want centered.
  79        self._yoffs = 0.5 * self._height + (
  80            220 if uiscale is bui.UIScale.SMALL else 190
  81        )
  82
  83        self._chest_yoffs = self._yoffs - 223
  84
  85        super().__init__(
  86            root_widget=bui.containerwidget(
  87                size=(self._width, self._height),
  88                toolbar_visibility='menu_full',
  89                scale=scale,
  90            ),
  91            transition=transition,
  92            origin_widget=origin_widget,
  93            # We're affected by screen size only at small ui-scale.
  94            refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL,
  95        )
  96
  97        self._title_text = bui.textwidget(
  98            parent=self._root_widget,
  99            position=(
 100                self._width * 0.5,
 101                self._yoffstop - (36 if uiscale is bui.UIScale.SMALL else 10),
 102            ),
 103            size=(0, 0),
 104            text=bui.Lstr(
 105                resource='chests.slotText',
 106                subs=[('${NUM}', str(index + 1))],
 107            ),
 108            color=bui.app.ui_v1.title_color,
 109            maxwidth=110.0 if uiscale is bui.UIScale.SMALL else 200,
 110            scale=0.9 if uiscale is bui.UIScale.SMALL else 1.1,
 111            h_align='center',
 112            v_align='center',
 113        )
 114        self._core_widgets.append(self._title_text)
 115
 116        if uiscale is bui.UIScale.SMALL:
 117            bui.containerwidget(
 118                edit=self._root_widget, on_cancel_call=self.main_window_back
 119            )
 120        else:
 121            btn = bui.buttonwidget(
 122                parent=self._root_widget,
 123                position=(50, self._yoffs - 44),
 124                size=(60, 55),
 125                scale=0.8,
 126                label=bui.charstr(bui.SpecialChar.BACK),
 127                button_type='backSmall',
 128                extra_touch_border_scale=2.0,
 129                autoselect=True,
 130                on_activate_call=self.main_window_back,
 131            )
 132            bui.containerwidget(edit=self._root_widget, cancel_button=btn)
 133            self._core_widgets.append(btn)
 134
 135        # Note: Don't need to explicitly clean this up. Just not adding
 136        # it to core_widgets so it will go away on next reset.
 137        self._loadingspinner = bui.spinnerwidget(
 138            parent=self._root_widget,
 139            position=(self._width * 0.5, self._height * 0.5),
 140            size=48,
 141            style='bomb',
 142        )
 143
 144        self._infotext = bui.textwidget(
 145            parent=self._root_widget,
 146            position=(self._width * 0.5, self._yoffs - 200),
 147            size=(0, 0),
 148            text='',
 149            maxwidth=700,
 150            scale=0.8,
 151            color=(0.6, 0.5, 0.6),
 152            h_align='center',
 153            v_align='center',
 154        )
 155        self._core_widgets.append(self._infotext)
 156
 157        plus = bui.app.plus
 158        if plus is None:
 159            self._error('Plus feature-set is not present.')
 160            return
 161
 162        if plus.accounts.primary is None:
 163            self._error(bui.Lstr(resource='notSignedInText'))
 164            return
 165
 166        # Start by showing info/options for our target chest. Note that
 167        # we always ask the server for these values even though we may
 168        # have them through our appmode subscription which updates the
 169        # chest UI. This is because the wait_for_connectivity()
 170        # mechanism will often bring our window up a split second before
 171        # the chest subscription receives its first values which would
 172        # lead us to incorrectly think there is no chest there. If we
 173        # want to optimize this in the future we could perhaps use local
 174        # values only if there is a chest present in them.
 175        assert not self._action_in_flight
 176        self._action_in_flight = True
 177        with plus.accounts.primary:
 178            plus.cloud.send_message_cb(
 179                bacommon.bs.ChestInfoMessage(chest_id=str(self._index)),
 180                on_response=bui.WeakCall(self._on_chest_info_response),
 181            )
 182
 183    @override
 184    def get_main_window_state(self) -> bui.MainWindowState:
 185        # Support recreating our window for back/refresh purposes.
 186        cls = type(self)
 187
 188        # Pull anything we need from self out here; if we do it in the
 189        # lambda we keep self alive which is bad.
 190        index = self._index
 191
 192        return bui.BasicMainWindowState(
 193            create_call=lambda transition, origin_widget: cls(
 194                index=index, transition=transition, origin_widget=origin_widget
 195            )
 196        )
 197
 198    def _update_time_display(self, unlock_time: datetime.datetime) -> None:
 199        # Once text disappears, kill our timer.
 200        if not self._time_string_text:
 201            self._time_string_timer = None
 202            return
 203        now = bui.utc_now_cloud()
 204        secs_till_open = max(0.0, (unlock_time - now).total_seconds())
 205        tstr = (
 206            bui.timestring(secs_till_open, centi=False)
 207            if secs_till_open > 0
 208            else ''
 209        )
 210        bui.textwidget(edit=self._time_string_text, text=tstr)
 211
 212    def _on_chest_info_response(
 213        self, response: bacommon.bs.ChestInfoResponse | Exception
 214    ) -> None:
 215        assert self._action_in_flight  # Should be us.
 216        self._action_in_flight = False
 217
 218        if isinstance(response, Exception):
 219            self._error(
 220                bui.Lstr(resource='internal.unableToCompleteTryAgainText'),
 221                minor=True,
 222            )
 223            return
 224
 225        if response.chest is None:
 226            self._show_about_chest_slots()
 227            return
 228
 229        assert response.user_tokens is not None
 230        self._show_chest_actions(response.user_tokens, response.chest)
 231
 232    def _on_chest_action_response(
 233        self, response: bacommon.bs.ChestActionResponse | Exception
 234    ) -> None:
 235        assert self._action_in_flight  # Should be us.
 236        self._action_in_flight = False
 237
 238        # Allow the root ui to resume its normal automatic value display
 239        # as soon as any animations we kick off here complete.
 240        self._chest_action_ui_pause = None
 241
 242        # Communication/local error:
 243        if isinstance(response, Exception):
 244            self._error(
 245                bui.Lstr(resource='internal.unableToCompleteTryAgainText'),
 246                minor=True,
 247            )
 248            return
 249
 250        # Server-side error:
 251        if response.error is not None:
 252            self._error(bui.Lstr(translate=('serverResponses', response.error)))
 253            return
 254
 255        toffs = 0.0
 256        # If there's contents listed in the response, show them.
 257        if response.contents is not None:
 258            toffs = self._show_chest_contents(response)
 259        else:
 260            # Otherwise we're done here; just close out our UI.
 261            self.main_window_back()
 262
 263        # Lastly, run any bundled effects.
 264        assert bui.app.classic is not None
 265        bui.app.classic.run_bs_client_effects(response.effects, delay=toffs)
 266
 267    def _show_chest_actions(
 268        self, user_tokens: int, chest: bacommon.bs.ChestInfoResponse.Chest
 269    ) -> None:
 270        """Show state for our chest."""
 271        # pylint: disable=too-many-locals
 272        # pylint: disable=cyclic-import
 273        from baclassic import (
 274            ClassicAppMode,
 275            CHEST_APPEARANCE_DISPLAY_INFOS,
 276            CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT,
 277        )
 278
 279        plus = bui.app.plus
 280        assert plus is not None
 281
 282        # We expect to be run under classic app mode.
 283        mode = bui.app.mode
 284        if not isinstance(mode, ClassicAppMode):
 285            self._error('Classic app mode not active.')
 286            return
 287
 288        self._reset()
 289
 290        self._chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get(
 291            chest.appearance, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT
 292        )
 293
 294        bui.textwidget(
 295            edit=self._title_text,
 296            text=bui.Lstr(
 297                translate=('displayItemNames', chest.appearance.pretty_name)
 298            ),
 299        )
 300
 301        imgsize = 145
 302        bui.imagewidget(
 303            parent=self._root_widget,
 304            position=(
 305                self._width * 0.5 - imgsize * 0.5,
 306                # self._height - 223 + self._yoffs,
 307                self._chest_yoffs,
 308            ),
 309            color=self._chestdisplayinfo.color,
 310            size=(imgsize, imgsize),
 311            texture=bui.gettexture(self._chestdisplayinfo.texclosed),
 312            tint_texture=bui.gettexture(self._chestdisplayinfo.texclosedtint),
 313            tint_color=self._chestdisplayinfo.tint,
 314            tint2_color=self._chestdisplayinfo.tint2,
 315        )
 316
 317        # Store the prize-sets so we can display odds/etc. Sort them
 318        # with largest weights first.
 319        self._prizesets = sorted(
 320            chest.prizesets, key=lambda s: s.weight, reverse=True
 321        )
 322
 323        if chest.unlock_tokens > 0:
 324            lsize = 30
 325            bui.imagewidget(
 326                parent=self._root_widget,
 327                position=(
 328                    self._width * 0.5 - imgsize * 0.4 - lsize * 0.5,
 329                    # self._height - 223 + 27.0 + self._yoffs,
 330                    self._chest_yoffs + 27.0,
 331                ),
 332                size=(lsize, lsize),
 333                texture=bui.gettexture('lock'),
 334            )
 335
 336        # Time string.
 337        if chest.unlock_tokens != 0:
 338            self._time_string_text = bui.textwidget(
 339                parent=self._root_widget,
 340                position=(
 341                    self._width * 0.5,
 342                    # self._height - 85 + self._yoffs
 343                    self._yoffs - 85,
 344                ),
 345                size=(0, 0),
 346                text='',
 347                maxwidth=700,
 348                scale=0.6,
 349                color=(0.6, 1.0, 0.6),
 350                h_align='center',
 351                v_align='center',
 352            )
 353            self._update_time_display(chest.unlock_time)
 354            self._time_string_timer = bui.AppTimer(
 355                1.0,
 356                repeat=True,
 357                call=bui.WeakCall(self._update_time_display, chest.unlock_time),
 358            )
 359
 360        # Allow watching an ad IF the server tells us we can AND we have
 361        # an ad ready to show.
 362        show_ad_button = (
 363            chest.unlock_tokens > 0
 364            and chest.ad_allow
 365            and plus.have_incentivized_ad()
 366        )
 367
 368        bwidth = 130
 369        bheight = 90
 370        bposy = -330 if chest.unlock_tokens == 0 else -340
 371        hspace = 20
 372        boffsx = (hspace * -0.5 - bwidth * 0.5) if show_ad_button else 0.0
 373
 374        self._open_now_button = bui.buttonwidget(
 375            parent=self._root_widget,
 376            position=(
 377                self._width * 0.5 - bwidth * 0.5 + boffsx,
 378                self._yoffs + bposy,
 379            ),
 380            size=(bwidth, bheight),
 381            label='',
 382            button_type='square',
 383            autoselect=True,
 384            on_activate_call=bui.WeakCall(
 385                self._open_press, user_tokens, chest.unlock_tokens
 386            ),
 387            enable_sound=False,
 388        )
 389        self._open_now_images = []
 390        self._open_now_texts = []
 391
 392        iconsize = 50
 393        if chest.unlock_tokens == 0:
 394            self._open_now_texts.append(
 395                bui.textwidget(
 396                    parent=self._root_widget,
 397                    text=bui.Lstr(resource='openText'),
 398                    position=(
 399                        self._width * 0.5 + boffsx,
 400                        self._yoffs + bposy + bheight * 0.5,
 401                    ),
 402                    color=(0, 1, 0),
 403                    draw_controller=self._open_now_button,
 404                    scale=0.7,
 405                    maxwidth=bwidth * 0.8,
 406                    size=(0, 0),
 407                    h_align='center',
 408                    v_align='center',
 409                )
 410            )
 411        else:
 412            self._open_now_texts.append(
 413                bui.textwidget(
 414                    parent=self._root_widget,
 415                    text=bui.Lstr(resource='openNowText'),
 416                    position=(
 417                        self._width * 0.5 + boffsx,
 418                        self._yoffs + bposy + bheight * 1.15,
 419                    ),
 420                    maxwidth=bwidth * 0.8,
 421                    scale=0.7,
 422                    color=(0.7, 1, 0.7),
 423                    size=(0, 0),
 424                    h_align='center',
 425                    v_align='center',
 426                )
 427            )
 428            self._open_now_images.append(
 429                bui.imagewidget(
 430                    parent=self._root_widget,
 431                    size=(iconsize, iconsize),
 432                    position=(
 433                        self._width * 0.5 - iconsize * 0.5 + boffsx,
 434                        self._yoffs + bposy + bheight * 0.35,
 435                    ),
 436                    draw_controller=self._open_now_button,
 437                    texture=bui.gettexture('coin'),
 438                )
 439            )
 440            self._open_now_texts.append(
 441                bui.textwidget(
 442                    parent=self._root_widget,
 443                    text=bui.Lstr(
 444                        resource='tokens.numTokensText',
 445                        subs=[('${COUNT}', str(chest.unlock_tokens))],
 446                    ),
 447                    position=(
 448                        self._width * 0.5 + boffsx,
 449                        self._yoffs + bposy + bheight * 0.25,
 450                    ),
 451                    scale=0.65,
 452                    color=(0, 1, 0),
 453                    draw_controller=self._open_now_button,
 454                    maxwidth=bwidth * 0.8,
 455                    size=(0, 0),
 456                    h_align='center',
 457                    v_align='center',
 458                )
 459            )
 460        self._open_now_spinner = bui.spinnerwidget(
 461            parent=self._root_widget,
 462            position=(
 463                self._width * 0.5 + boffsx,
 464                self._yoffs + bposy + 0.5 * bheight,
 465            ),
 466            visible=False,
 467        )
 468
 469        if show_ad_button:
 470            bui.textwidget(
 471                parent=self._root_widget,
 472                text=bui.Lstr(resource='chests.reduceWaitText'),
 473                position=(
 474                    self._width * 0.5 + hspace * 0.5 + bwidth * 0.5,
 475                    self._yoffs + bposy + bheight * 1.15,
 476                ),
 477                maxwidth=bwidth * 0.8,
 478                scale=0.7,
 479                color=(0.7, 1, 0.7),
 480                size=(0, 0),
 481                h_align='center',
 482                v_align='center',
 483            )
 484            self._watch_ad_button = bui.buttonwidget(
 485                parent=self._root_widget,
 486                position=(
 487                    self._width * 0.5 + hspace * 0.5,
 488                    self._yoffs + bposy,
 489                ),
 490                size=(bwidth, bheight),
 491                label='',
 492                button_type='square',
 493                autoselect=True,
 494                on_activate_call=bui.WeakCall(self._watch_ad_press),
 495                enable_sound=False,
 496            )
 497            bui.imagewidget(
 498                parent=self._root_widget,
 499                size=(iconsize, iconsize),
 500                position=(
 501                    self._width * 0.5
 502                    + hspace * 0.5
 503                    + bwidth * 0.5
 504                    - iconsize * 0.5,
 505                    self._yoffs + bposy + bheight * 0.35,
 506                ),
 507                draw_controller=self._watch_ad_button,
 508                color=(1.5, 1.0, 2.0),
 509                texture=bui.gettexture('tv'),
 510            )
 511            # Note to self: AdMob requires rewarded ad usage
 512            # specifically says 'Ad' in it.
 513            bui.textwidget(
 514                parent=self._root_widget,
 515                text=bui.Lstr(resource='watchAnAdText'),
 516                position=(
 517                    self._width * 0.5 + hspace * 0.5 + bwidth * 0.5,
 518                    self._yoffs + bposy + bheight * 0.25,
 519                ),
 520                scale=0.65,
 521                color=(0, 1, 0),
 522                draw_controller=self._watch_ad_button,
 523                maxwidth=bwidth * 0.8,
 524                size=(0, 0),
 525                h_align='center',
 526                v_align='center',
 527            )
 528
 529        self._show_odds(initial_highlighted_row=-1)
 530
 531    def _highlight_odds_row(self, row: int, extra: bool = False) -> None:
 532
 533        for rindex, imgs in self._prizesetimgs.items():
 534            opacity = (
 535                (0.9 if extra else 0.75)
 536                if rindex == row
 537                else (0.4 if extra else 0.5)
 538            )
 539            for img in imgs:
 540                if img:
 541                    bui.imagewidget(edit=img, opacity=opacity)
 542
 543        for rindex, txts in self._prizesettxts.items():
 544            opacity = (
 545                (0.9 if extra else 0.75)
 546                if rindex == row
 547                else (0.4 if extra else 0.5)
 548            )
 549            for txt in txts:
 550                if txt:
 551                    bui.textwidget(edit=txt, color=(0.7, 0.65, 1, opacity))
 552
 553    def _show_odds(
 554        self,
 555        *,
 556        initial_highlighted_row: int,
 557        initial_highlighted_extra: bool = False,
 558    ) -> None:
 559        # pylint: disable=too-many-locals
 560        xoffs = 110
 561
 562        totalweight = max(0.001, sum(t.weight for t in self._prizesets))
 563
 564        rowheight = 25
 565        totalheight = (len(self._prizesets) + 1) * rowheight
 566        x = self._width * 0.5 + xoffs
 567        y = self._yoffs - 150.0 + totalheight * 0.5
 568
 569        # Title.
 570        bui.textwidget(
 571            parent=self._root_widget,
 572            text=bui.Lstr(resource='chests.prizeOddsText'),
 573            color=(0.7, 0.65, 1, 0.5),
 574            flatness=1.0,
 575            shadow=1.0,
 576            position=(x, y),
 577            scale=0.55,
 578            size=(0, 0),
 579            h_align='left',
 580            v_align='center',
 581        )
 582        y -= 5.0
 583
 584        prizesettxts: list[bui.Widget]
 585        prizesetimgs: list[bui.Widget]
 586
 587        def _mkicon(img: str) -> None:
 588            iconsize = 20.0
 589            nonlocal x
 590            nonlocal prizesetimgs
 591            prizesetimgs.append(
 592                bui.imagewidget(
 593                    parent=self._root_widget,
 594                    size=(iconsize, iconsize),
 595                    position=(x, y - iconsize * 0.5),
 596                    texture=bui.gettexture(img),
 597                    opacity=0.4,
 598                )
 599            )
 600            x += iconsize
 601
 602        def _mktxt(txt: str, advance: bool = True) -> None:
 603            tscale = 0.45
 604            nonlocal x
 605            nonlocal prizesettxts
 606            prizesettxts.append(
 607                bui.textwidget(
 608                    parent=self._root_widget,
 609                    text=txt,
 610                    flatness=1.0,
 611                    shadow=1.0,
 612                    position=(x, y),
 613                    scale=tscale,
 614                    size=(0, 0),
 615                    h_align='left',
 616                    v_align='center',
 617                )
 618            )
 619            if advance:
 620                x += (bui.get_string_width(txt, suppress_warning=True)) * tscale
 621
 622        self._prizesettxts = {}
 623        self._prizesetimgs = {}
 624
 625        for i, p in enumerate(self._prizesets):
 626            prizesettxts = self._prizesettxts.setdefault(i, [])
 627            prizesetimgs = self._prizesetimgs.setdefault(i, [])
 628            x = self._width * 0.5 + xoffs
 629            y -= rowheight
 630            percent = 100.0 * p.weight / totalweight
 631
 632            # Show decimals only if we get very small percentages (looks
 633            # better than rounding as '0%').
 634            percenttxt = (
 635                f'{percent:.2f}%:'
 636                if percent < 0.095
 637                else (
 638                    f'{percent:.1f}%:'
 639                    if percent < 0.95
 640                    else f'{round(percent)}%:'
 641                )
 642            )
 643
 644            # We advance manually here to keep values lined up
 645            # (otherwise single digit percent rows don't line up with
 646            # double digit ones).
 647            _mktxt(percenttxt, advance=False)
 648            x += 35.0
 649
 650            for item in p.contents:
 651                x += 5.0
 652                if isinstance(item.item, bacommon.bs.TicketsDisplayItem):
 653                    _mktxt(str(item.item.count))
 654                    _mkicon('tickets')
 655                elif isinstance(item.item, bacommon.bs.TokensDisplayItem):
 656                    _mktxt(str(item.item.count))
 657                    _mkicon('coin')
 658                else:
 659                    # For other cases just fall back on text desc.
 660                    #
 661                    # Translate the wrapper description and apply any subs.
 662                    descfin = bui.Lstr(
 663                        translate=('serverResponses', item.description)
 664                    ).evaluate()
 665                    subs = (
 666                        []
 667                        if item.description_subs is None
 668                        else item.description_subs
 669                    )
 670                    assert len(subs) % 2 == 0  # Should always be even.
 671                    for j in range(0, len(subs) - 1, 2):
 672                        descfin = descfin.replace(subs[j], subs[j + 1])
 673                    _mktxt(descfin)
 674        self._highlight_odds_row(
 675            initial_highlighted_row, extra=initial_highlighted_extra
 676        )
 677
 678    def _open_press(self, user_tokens: int, token_payment: int) -> None:
 679        from bauiv1lib.gettokens import show_get_tokens_prompt
 680
 681        bui.getsound('click01').play()
 682
 683        # Allow only one in-flight action at once.
 684        if self._action_in_flight:
 685            bui.screenmessage(
 686                bui.Lstr(resource='pleaseWaitText'), color=(1, 0, 0)
 687            )
 688            bui.getsound('error').play()
 689            return
 690
 691        plus = bui.app.plus
 692        assert plus is not None
 693
 694        if plus.accounts.primary is None:
 695            self._error(bui.Lstr(resource='notSignedInText'))
 696            return
 697
 698        # Offer to purchase tokens if they don't have enough.
 699        if user_tokens < token_payment:
 700            # Hack: We disable normal swish for the open button and it
 701            # seems weird without a swish here, so explicitly do one.
 702            bui.getsound('swish').play()
 703            show_get_tokens_prompt()
 704            return
 705
 706        self._action_in_flight = True
 707
 708        # Pause implicit root-ui updates at least until we're done with
 709        # this message, as we might want to explicitly animate stuff from
 710        # the results and don't want live values to jump the gun.
 711        self._chest_action_ui_pause = bui.RootUIUpdatePause()
 712
 713        with plus.accounts.primary:
 714            plus.cloud.send_message_cb(
 715                bacommon.bs.ChestActionMessage(
 716                    chest_id=str(self._index),
 717                    action=bacommon.bs.ChestActionMessage.Action.UNLOCK,
 718                    token_payment=token_payment,
 719                ),
 720                on_response=bui.WeakCall(self._on_chest_action_response),
 721            )
 722
 723        # Convey that something is in progress.
 724        if self._open_now_button:
 725            bui.spinnerwidget(edit=self._open_now_spinner, visible=True)
 726            for twidget in self._open_now_texts:
 727                bui.textwidget(edit=twidget, color=(1, 1, 1, 0.2))
 728            for iwidget in self._open_now_images:
 729                bui.imagewidget(edit=iwidget, opacity=0.2)
 730
 731    def _watch_ad_press(self) -> None:
 732
 733        bui.getsound('click01').play()
 734
 735        # Allow only one in-flight action at once.
 736        if self._action_in_flight:
 737            bui.screenmessage(
 738                bui.Lstr(resource='pleaseWaitText'), color=(1, 0, 0)
 739            )
 740            bui.getsound('error').play()
 741            return
 742
 743        assert bui.app.classic is not None
 744
 745        self._action_in_flight = True
 746        bui.app.classic.ads.show_ad_2(
 747            'reduce_chest_wait',
 748            on_completion_call=bui.WeakCall(self._watch_ad_complete),
 749        )
 750
 751        # Convey that something is in progress.
 752        if self._watch_ad_button:
 753            bui.buttonwidget(edit=self._watch_ad_button, color=(0.4, 0.4, 0.4))
 754
 755    def _watch_ad_complete(self, actually_showed: bool) -> None:
 756
 757        assert self._action_in_flight  # Should be ad view.
 758        self._action_in_flight = False
 759
 760        if not actually_showed:
 761            return
 762
 763        # Allow only one in-flight action at once.
 764        if self._action_in_flight:
 765            bui.screenmessage(
 766                bui.Lstr(resource='pleaseWaitText'), color=(1, 0, 0)
 767            )
 768            bui.getsound('error').play()
 769            return
 770
 771        plus = bui.app.plus
 772        assert plus is not None
 773
 774        if plus.accounts.primary is None:
 775            self._error(bui.Lstr(resource='notSignedInText'))
 776            return
 777
 778        self._action_in_flight = True
 779
 780        # Pause implicit root-ui updates at least until we're done with
 781        # this message, as we might want to explicitly animate stuff from
 782        # the results and don't want live values to jump the gun.
 783        self._chest_action_ui_pause = bui.RootUIUpdatePause()
 784
 785        with plus.accounts.primary:
 786            plus.cloud.send_message_cb(
 787                bacommon.bs.ChestActionMessage(
 788                    chest_id=str(self._index),
 789                    action=bacommon.bs.ChestActionMessage.Action.AD,
 790                    token_payment=0,
 791                ),
 792                on_response=bui.WeakCall(self._on_chest_action_response),
 793            )
 794
 795    def _reset(self) -> None:
 796        """Clear all non-permanent widgets and clear infotext."""
 797        for widget in self._root_widget.get_children():
 798            if widget not in self._core_widgets:
 799                widget.delete()
 800        bui.textwidget(edit=self._infotext, text='', color=(1, 1, 1))
 801
 802    def _error(self, msg: str | bui.Lstr, minor: bool = False) -> None:
 803        """Put ourself in an error state with a visible error message."""
 804        self._reset()
 805        bui.textwidget(
 806            edit=self._infotext,
 807            text=msg,
 808            color=(1, 0.5, 0.5) if minor else (1, 0, 0),
 809        )
 810
 811    def _show_about_chest_slots(self) -> None:
 812        # No-op if our ui is dead.
 813        if not self._root_widget:
 814            return
 815
 816        self._reset()
 817        bui.textwidget(
 818            edit=self._infotext,
 819            text=bui.Lstr(resource='chests.slotDescriptionText'),
 820            color=(1, 1, 1),
 821        )
 822
 823    def _show_chest_contents(
 824        self, response: bacommon.bs.ChestActionResponse
 825    ) -> float:
 826        # pylint: disable=too-many-locals
 827
 828        from baclassic import show_display_item
 829
 830        # No-op if our ui is dead.
 831        if not self._root_widget:
 832            return 0.0
 833
 834        assert response.contents is not None
 835
 836        # Insert test items for testing.
 837        if bool(False):
 838            response.contents += [
 839                bacommon.bs.DisplayItemWrapper.for_display_item(
 840                    bacommon.bs.TestDisplayItem()
 841                )
 842            ]
 843
 844        tincr = 0.4
 845        tendoffs = tincr * 4.0
 846        toffs = 0.0
 847
 848        bui.getsound('revUp').play(volume=2.0)
 849
 850        # Show nothing but the chest icon and animate it shaking.
 851        self._reset()
 852        imgsize = 145
 853        assert self._chestdisplayinfo is not None
 854        img = bui.imagewidget(
 855            parent=self._root_widget,
 856            color=self._chestdisplayinfo.color,
 857            texture=bui.gettexture(self._chestdisplayinfo.texclosed),
 858            tint_texture=bui.gettexture(self._chestdisplayinfo.texclosedtint),
 859            tint_color=self._chestdisplayinfo.tint,
 860            tint2_color=self._chestdisplayinfo.tint2,
 861        )
 862
 863        def _set_img(x: float, scale: float) -> None:
 864            if not img:
 865                return
 866            bui.imagewidget(
 867                edit=img,
 868                position=(
 869                    self._width * 0.5 - imgsize * scale * 0.5 + x,
 870                    self._yoffs - 223 + imgsize * 0.5 - imgsize * scale * 0.5,
 871                ),
 872                size=(imgsize * scale, imgsize * scale),
 873            )
 874
 875        # Set initial place.
 876        _set_img(0.0, 1.0)
 877
 878        sign = 1.0
 879        while toffs < tendoffs:
 880            toffs += 0.03 * random.uniform(0.5, 1.5)
 881            sign = -sign
 882            bui.apptimer(
 883                toffs,
 884                bui.Call(
 885                    _set_img,
 886                    x=(
 887                        20.0
 888                        * random.uniform(0.3, 1.0)
 889                        * math.pow(toffs / tendoffs, 2.0)
 890                        * sign
 891                    ),
 892                    scale=1.0 - 0.2 * math.pow(toffs / tendoffs, 2.0),
 893                ),
 894            )
 895
 896        xspacing = 100
 897        xoffs = -0.5 * (len(response.contents) - 1) * xspacing
 898        bui.apptimer(
 899            toffs - 0.2, lambda: bui.getsound('corkPop2').play(volume=4.0)
 900        )
 901        # Play a variety of voice sounds.
 902
 903        # We keep a global list of voice options which we randomly pull
 904        # from and refill when empty. This ensures everything gets
 905        # played somewhat frequently and minimizes annoying repeats.
 906        global _g_open_voices  # pylint: disable=global-statement
 907        if not _g_open_voices:
 908            _g_open_voices = [
 909                (0.3, 'woo3', 2.5),
 910                (0.1, 'gasp', 1.3),
 911                (0.2, 'woo2', 2.0),
 912                (0.2, 'wow', 2.0),
 913                (0.2, 'kronk2', 2.0),
 914                (0.2, 'mel03', 2.0),
 915                (0.2, 'aww', 2.0),
 916                (0.4, 'nice', 2.0),
 917                (0.3, 'yeah', 1.5),
 918                (0.2, 'woo', 1.0),
 919                (0.5, 'ooh', 0.8),
 920            ]
 921
 922        voicetimeoffs, voicename, volume = _g_open_voices.pop(
 923            random.randrange(len(_g_open_voices))
 924        )
 925        bui.apptimer(
 926            toffs + voicetimeoffs,
 927            lambda: bui.getsound(voicename).play(volume=volume),
 928        )
 929
 930        toffsopen = toffs
 931        bui.apptimer(toffs, bui.WeakCall(self._show_chest_opening))
 932        toffs += tincr * 1.0
 933        width = xspacing * 0.95
 934
 935        for item in response.contents:
 936            toffs += tincr
 937            bui.apptimer(
 938                toffs - 0.1, lambda: bui.getsound('cashRegister').play()
 939            )
 940            bui.apptimer(
 941                toffs,
 942                strict_partial(
 943                    show_display_item,
 944                    item,
 945                    self._root_widget,
 946                    pos=(
 947                        self._width * 0.5 + xoffs,
 948                        self._yoffs - 250.0,
 949                    ),
 950                    width=width,
 951                ),
 952            )
 953            xoffs += xspacing
 954        toffs += tincr
 955        bui.apptimer(toffs, bui.WeakCall(self._show_done_button))
 956
 957        self._show_odds(initial_highlighted_row=-1)
 958
 959        # Store this for later
 960        self._prizeindex = response.prizeindex
 961
 962        # The final result was already randomly selected on the server,
 963        # but we want to give the illusion of randomness here, so cycle
 964        # through highlighting our options and stop on the winner when
 965        # the chest opens. To do this, we start at the end at the prize
 966        # and work backwards setting timers.
 967        if self._prizesets:
 968            toffs2 = toffsopen - 0.01
 969            amt = 0.02
 970            i = self._prizeindex
 971            while toffs2 > 0.0:
 972                bui.apptimer(
 973                    toffs2,
 974                    bui.WeakCall(self._highlight_odds_row, i),
 975                )
 976                toffs2 -= amt
 977                amt *= 1.05 * random.uniform(0.9, 1.1)
 978                i = (i - 1) % len(self._prizesets)
 979
 980        # Let the caller know how long we'll take in case they want to
 981        # schedule stuff after.
 982        return toffs + tincr
 983
 984    def _show_chest_opening(self) -> None:
 985
 986        # No-op if our ui is dead.
 987        if not self._root_widget:
 988            return
 989
 990        self._reset()
 991        imgsize = 145
 992        bui.getsound('hiss').play()
 993        assert self._chestdisplayinfo is not None
 994        img = bui.imagewidget(
 995            parent=self._root_widget,
 996            color=self._chestdisplayinfo.color,
 997            texture=bui.gettexture(self._chestdisplayinfo.texopen),
 998            tint_texture=bui.gettexture(self._chestdisplayinfo.texopentint),
 999            tint_color=self._chestdisplayinfo.tint,
1000            tint2_color=self._chestdisplayinfo.tint2,
1001        )
1002        tincr = 0.8
1003        tendoffs = tincr * 2.0
1004        toffs = 0.0
1005
1006        def _set_img(x: float, scale: float) -> None:
1007            if not img:
1008                return
1009            bui.imagewidget(
1010                edit=img,
1011                position=(
1012                    self._width * 0.5 - imgsize * scale * 0.5 + x,
1013                    self._yoffs - 223 + imgsize * 0.5 - imgsize * scale * 0.5,
1014                ),
1015                size=(imgsize * scale, imgsize * scale),
1016            )
1017
1018        # Set initial place.
1019        _set_img(0.0, 1.0)
1020
1021        sign = 1.0
1022        while toffs < tendoffs:
1023            toffs += 0.03 * random.uniform(0.5, 1.5)
1024            sign = -sign
1025            # Note: we speed x along here (multing toffs) so position
1026            # comes to rest before scale.
1027            bui.apptimer(
1028                toffs,
1029                bui.Call(
1030                    _set_img,
1031                    x=(
1032                        1.0
1033                        * random.uniform(0.3, 1.0)
1034                        * (
1035                            1.0
1036                            - math.pow(min(1.0, 3.0 * toffs / tendoffs), 2.0)
1037                        )
1038                        * sign
1039                    ),
1040                    scale=1.0 - 0.1 * math.pow(toffs / tendoffs, 0.5),
1041                ),
1042            )
1043
1044        self._show_odds(
1045            initial_highlighted_row=self._prizeindex,
1046            initial_highlighted_extra=True,
1047        )
1048
1049    def _show_done_button(self) -> None:
1050        # No-op if our ui is dead.
1051        if not self._root_widget:
1052            return
1053
1054        bwidth = 200
1055        bheight = 60
1056
1057        btn = bui.buttonwidget(
1058            parent=self._root_widget,
1059            position=(
1060                self._width * 0.5 - bwidth * 0.5,
1061                self._yoffs - 350,
1062            ),
1063            size=(bwidth, bheight),
1064            label=bui.Lstr(resource='doneText'),
1065            autoselect=True,
1066            on_activate_call=self.main_window_back,
1067        )
1068        bui.containerwidget(edit=self._root_widget, start_button=btn)

Allows viewing and performing operations on a chest.

ChestWindow( index: int, transition: str | None = 'in_right', origin_widget: _bauiv1.Widget | None = None)
 28    def __init__(
 29        self,
 30        index: int,
 31        transition: str | None = 'in_right',
 32        origin_widget: bui.Widget | None = None,
 33    ):
 34        self._index = index
 35
 36        assert bui.app.classic is not None
 37        uiscale = bui.app.ui_v1.uiscale
 38        self._width = 1200 if uiscale is bui.UIScale.SMALL else 650
 39        self._height = 800 if uiscale is bui.UIScale.SMALL else 450
 40        self._action_in_flight = False
 41        self._open_now_button: bui.Widget | None = None
 42        self._open_now_spinner: bui.Widget | None = None
 43        self._open_now_texts: list[bui.Widget] = []
 44        self._open_now_images: list[bui.Widget] = []
 45        self._watch_ad_button: bui.Widget | None = None
 46        self._time_string_timer: bui.AppTimer | None = None
 47        self._time_string_text: bui.Widget | None = None
 48        self._prizesets: list[bacommon.bs.ChestInfoResponse.Chest.PrizeSet] = []
 49        self._prizeindex = -1
 50        self._prizesettxts: dict[int, list[bui.Widget]] = {}
 51        self._prizesetimgs: dict[int, list[bui.Widget]] = {}
 52        self._chestdisplayinfo: baclassic.ChestAppearanceDisplayInfo | None = (
 53            None
 54        )
 55
 56        self._chest_action_ui_pause: bui.RootUIUpdatePause | None = None
 57
 58        # The set of widgets we keep when doing a clear.
 59        self._core_widgets: list[bui.Widget] = []
 60
 61        # Do some fancy math to fill all available screen area up to the
 62        # size of our backing container. This lets us fit to the exact
 63        # screen shape at small ui scale.
 64        screensize = bui.get_virtual_screen_size()
 65        scale = (
 66            1.8
 67            if uiscale is bui.UIScale.SMALL
 68            else 1.1 if uiscale is bui.UIScale.MEDIUM else 0.9
 69        )
 70        # Calc screen size in our local container space and clamp to a
 71        # bit smaller than our container size.
 72        target_height = min(self._height - 120, screensize[1] / scale)
 73
 74        # To get top/left coords, go to the center of our window and
 75        # offset by half the width/height of our target area.
 76        self._yoffstop = 0.5 * self._height + 0.5 * target_height + 18
 77
 78        # Offset for stuff we want centered.
 79        self._yoffs = 0.5 * self._height + (
 80            220 if uiscale is bui.UIScale.SMALL else 190
 81        )
 82
 83        self._chest_yoffs = self._yoffs - 223
 84
 85        super().__init__(
 86            root_widget=bui.containerwidget(
 87                size=(self._width, self._height),
 88                toolbar_visibility='menu_full',
 89                scale=scale,
 90            ),
 91            transition=transition,
 92            origin_widget=origin_widget,
 93            # We're affected by screen size only at small ui-scale.
 94            refresh_on_screen_size_changes=uiscale is bui.UIScale.SMALL,
 95        )
 96
 97        self._title_text = bui.textwidget(
 98            parent=self._root_widget,
 99            position=(
100                self._width * 0.5,
101                self._yoffstop - (36 if uiscale is bui.UIScale.SMALL else 10),
102            ),
103            size=(0, 0),
104            text=bui.Lstr(
105                resource='chests.slotText',
106                subs=[('${NUM}', str(index + 1))],
107            ),
108            color=bui.app.ui_v1.title_color,
109            maxwidth=110.0 if uiscale is bui.UIScale.SMALL else 200,
110            scale=0.9 if uiscale is bui.UIScale.SMALL else 1.1,
111            h_align='center',
112            v_align='center',
113        )
114        self._core_widgets.append(self._title_text)
115
116        if uiscale is bui.UIScale.SMALL:
117            bui.containerwidget(
118                edit=self._root_widget, on_cancel_call=self.main_window_back
119            )
120        else:
121            btn = bui.buttonwidget(
122                parent=self._root_widget,
123                position=(50, self._yoffs - 44),
124                size=(60, 55),
125                scale=0.8,
126                label=bui.charstr(bui.SpecialChar.BACK),
127                button_type='backSmall',
128                extra_touch_border_scale=2.0,
129                autoselect=True,
130                on_activate_call=self.main_window_back,
131            )
132            bui.containerwidget(edit=self._root_widget, cancel_button=btn)
133            self._core_widgets.append(btn)
134
135        # Note: Don't need to explicitly clean this up. Just not adding
136        # it to core_widgets so it will go away on next reset.
137        self._loadingspinner = bui.spinnerwidget(
138            parent=self._root_widget,
139            position=(self._width * 0.5, self._height * 0.5),
140            size=48,
141            style='bomb',
142        )
143
144        self._infotext = bui.textwidget(
145            parent=self._root_widget,
146            position=(self._width * 0.5, self._yoffs - 200),
147            size=(0, 0),
148            text='',
149            maxwidth=700,
150            scale=0.8,
151            color=(0.6, 0.5, 0.6),
152            h_align='center',
153            v_align='center',
154        )
155        self._core_widgets.append(self._infotext)
156
157        plus = bui.app.plus
158        if plus is None:
159            self._error('Plus feature-set is not present.')
160            return
161
162        if plus.accounts.primary is None:
163            self._error(bui.Lstr(resource='notSignedInText'))
164            return
165
166        # Start by showing info/options for our target chest. Note that
167        # we always ask the server for these values even though we may
168        # have them through our appmode subscription which updates the
169        # chest UI. This is because the wait_for_connectivity()
170        # mechanism will often bring our window up a split second before
171        # the chest subscription receives its first values which would
172        # lead us to incorrectly think there is no chest there. If we
173        # want to optimize this in the future we could perhaps use local
174        # values only if there is a chest present in them.
175        assert not self._action_in_flight
176        self._action_in_flight = True
177        with plus.accounts.primary:
178            plus.cloud.send_message_cb(
179                bacommon.bs.ChestInfoMessage(chest_id=str(self._index)),
180                on_response=bui.WeakCall(self._on_chest_info_response),
181            )

Create a MainWindow given a root widget and transition info.

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

@override
def get_main_window_state(self) -> bauiv1.MainWindowState:
183    @override
184    def get_main_window_state(self) -> bui.MainWindowState:
185        # Support recreating our window for back/refresh purposes.
186        cls = type(self)
187
188        # Pull anything we need from self out here; if we do it in the
189        # lambda we keep self alive which is bad.
190        index = self._index
191
192        return bui.BasicMainWindowState(
193            create_call=lambda transition, origin_widget: cls(
194                index=index, transition=transition, origin_widget=origin_widget
195            )
196        )

Return a WindowState to recreate this window, if supported.

class ChestWindow0(ChestWindow):
1078class ChestWindow0(ChestWindow):
1079    """Child class of ChestWindow for slighty hackish reasons."""

Child class of ChestWindow for slighty hackish reasons.

class ChestWindow1(ChestWindow):
1082class ChestWindow1(ChestWindow):
1083    """Child class of ChestWindow for slighty hackish reasons."""

Child class of ChestWindow for slighty hackish reasons.

class ChestWindow2(ChestWindow):
1086class ChestWindow2(ChestWindow):
1087    """Child class of ChestWindow for slighty hackish reasons."""

Child class of ChestWindow for slighty hackish reasons.

class ChestWindow3(ChestWindow):
1090class ChestWindow3(ChestWindow):
1091    """Child class of ChestWindow for slighty hackish reasons."""

Child class of ChestWindow for slighty hackish reasons.