bastd.ui.specialoffer

UI for presenting sales/etc.

  1# Released under the MIT License. See LICENSE for details.
  2#
  3"""UI for presenting sales/etc."""
  4
  5from __future__ import annotations
  6
  7import copy
  8from typing import TYPE_CHECKING
  9
 10import ba
 11import ba.internal
 12
 13if TYPE_CHECKING:
 14    from typing import Any
 15
 16
 17class SpecialOfferWindow(ba.Window):
 18    """Window for presenting sales/etc."""
 19
 20    def __init__(self, offer: dict[str, Any], transition: str = 'in_right'):
 21        # pylint: disable=too-many-statements
 22        # pylint: disable=too-many-branches
 23        # pylint: disable=too-many-locals
 24        from ba.internal import get_store_item_display_size, get_clean_price
 25        from ba import SpecialChar
 26        from bastd.ui.store import item as storeitemui
 27
 28        self._cancel_delay = offer.get('cancelDelay', 0)
 29
 30        # First thing: if we're offering pro or an IAP, see if we have a
 31        # price for it.
 32        # If not, abort and go into zombie mode (the user should never see
 33        # us that way).
 34
 35        real_price: str | None
 36
 37        # Misnomer: 'pro' actually means offer 'pro_sale'.
 38        if offer['item'] in ['pro', 'pro_fullprice']:
 39            real_price = ba.internal.get_price(
 40                'pro' if offer['item'] == 'pro_fullprice' else 'pro_sale'
 41            )
 42            if real_price is None and ba.app.debug_build:
 43                print('NOTE: Faking prices for debug build.')
 44                real_price = '$1.23'
 45            zombie = real_price is None
 46        elif isinstance(offer['price'], str):
 47            # (a string price implies IAP id)
 48            real_price = ba.internal.get_price(offer['price'])
 49            if real_price is None and ba.app.debug_build:
 50                print('NOTE: Faking price for debug build.')
 51                real_price = '$1.23'
 52            zombie = real_price is None
 53        else:
 54            real_price = None
 55            zombie = False
 56        if real_price is None:
 57            real_price = '?'
 58
 59        if offer['item'] in ['pro', 'pro_fullprice']:
 60            self._offer_item = 'pro'
 61        else:
 62            self._offer_item = offer['item']
 63
 64        # If we wanted a real price but didn't find one, go zombie.
 65        if zombie:
 66            return
 67
 68        # This can pop up suddenly, so lets block input for 1 second.
 69        ba.internal.lock_all_input()
 70        ba.timer(1.0, ba.internal.unlock_all_input, timetype=ba.TimeType.REAL)
 71        ba.playsound(ba.getsound('ding'))
 72        ba.timer(
 73            0.3,
 74            lambda: ba.playsound(ba.getsound('ooh')),
 75            timetype=ba.TimeType.REAL,
 76        )
 77        self._offer = copy.deepcopy(offer)
 78        self._width = 580
 79        self._height = 590
 80        uiscale = ba.app.ui.uiscale
 81        super().__init__(
 82            root_widget=ba.containerwidget(
 83                size=(self._width, self._height),
 84                transition=transition,
 85                scale=(
 86                    1.2
 87                    if uiscale is ba.UIScale.SMALL
 88                    else 1.15
 89                    if uiscale is ba.UIScale.MEDIUM
 90                    else 1.0
 91                ),
 92                stack_offset=(0, -15)
 93                if uiscale is ba.UIScale.SMALL
 94                else (0, 0),
 95            )
 96        )
 97        self._is_bundle_sale = False
 98        try:
 99            if offer['item'] in ['pro', 'pro_fullprice']:
100                original_price_str = ba.internal.get_price('pro')
101                if original_price_str is None:
102                    original_price_str = '?'
103                new_price_str = ba.internal.get_price('pro_sale')
104                if new_price_str is None:
105                    new_price_str = '?'
106                percent_off_text = ''
107            else:
108                # If the offer includes bonus tickets, it's a bundle-sale.
109                if (
110                    'bonusTickets' in offer
111                    and offer['bonusTickets'] is not None
112                ):
113                    self._is_bundle_sale = True
114                original_price = ba.internal.get_v1_account_misc_read_val(
115                    'price.' + self._offer_item, 9999
116                )
117
118                # For pure ticket prices we can show a percent-off.
119                if isinstance(offer['price'], int):
120                    new_price = offer['price']
121                    tchar = ba.charstr(SpecialChar.TICKET)
122                    original_price_str = tchar + str(original_price)
123                    new_price_str = tchar + str(new_price)
124                    percent_off = int(
125                        round(
126                            100.0 - (float(new_price) / original_price) * 100.0
127                        )
128                    )
129                    percent_off_text = ' ' + ba.Lstr(
130                        resource='store.salePercentText'
131                    ).evaluate().replace('${PERCENT}', str(percent_off))
132                else:
133                    original_price_str = new_price_str = '?'
134                    percent_off_text = ''
135
136        except Exception:
137            print(f'Offer: {offer}')
138            ba.print_exception('Error setting up special-offer')
139            original_price_str = new_price_str = '?'
140            percent_off_text = ''
141
142        # If its a bundle sale, change the title.
143        if self._is_bundle_sale:
144            sale_text = ba.Lstr(
145                resource='store.saleBundleText',
146                fallback_resource='store.saleText',
147            ).evaluate()
148        else:
149            # For full pro we say 'Upgrade?' since its not really a sale.
150            if offer['item'] == 'pro_fullprice':
151                sale_text = ba.Lstr(
152                    resource='store.upgradeQuestionText',
153                    fallback_resource='store.saleExclaimText',
154                ).evaluate()
155            else:
156                sale_text = ba.Lstr(
157                    resource='store.saleExclaimText',
158                    fallback_resource='store.saleText',
159                ).evaluate()
160
161        self._title_text = ba.textwidget(
162            parent=self._root_widget,
163            position=(self._width * 0.5, self._height - 40),
164            size=(0, 0),
165            text=sale_text
166            + (
167                (' ' + ba.Lstr(resource='store.oneTimeOnlyText').evaluate())
168                if self._offer['oneTimeOnly']
169                else ''
170            )
171            + percent_off_text,
172            h_align='center',
173            v_align='center',
174            maxwidth=self._width * 0.9 - 220,
175            scale=1.4,
176            color=(0.3, 1, 0.3),
177        )
178
179        self._flash_on = False
180        self._flashing_timer: ba.Timer | None = ba.Timer(
181            0.05,
182            ba.WeakCall(self._flash_cycle),
183            repeat=True,
184            timetype=ba.TimeType.REAL,
185        )
186        ba.timer(
187            0.6, ba.WeakCall(self._stop_flashing), timetype=ba.TimeType.REAL
188        )
189
190        size = get_store_item_display_size(self._offer_item)
191        display: dict[str, Any] = {}
192        storeitemui.instantiate_store_item_display(
193            self._offer_item,
194            display,
195            parent_widget=self._root_widget,
196            b_pos=(
197                self._width * 0.5
198                - size[0] * 0.5
199                + 10
200                - ((size[0] * 0.5 + 30) if self._is_bundle_sale else 0),
201                self._height * 0.5
202                - size[1] * 0.5
203                + 20
204                + (20 if self._is_bundle_sale else 0),
205            ),
206            b_width=size[0],
207            b_height=size[1],
208            button=not self._is_bundle_sale,
209        )
210
211        # Wire up the parts we need.
212        if self._is_bundle_sale:
213            self._plus_text = ba.textwidget(
214                parent=self._root_widget,
215                position=(self._width * 0.5, self._height * 0.5 + 50),
216                size=(0, 0),
217                text='+',
218                h_align='center',
219                v_align='center',
220                maxwidth=self._width * 0.9,
221                scale=1.4,
222                color=(0.5, 0.5, 0.5),
223            )
224            self._plus_tickets = ba.textwidget(
225                parent=self._root_widget,
226                position=(self._width * 0.5 + 120, self._height * 0.5 + 50),
227                size=(0, 0),
228                text=ba.charstr(SpecialChar.TICKET_BACKING)
229                + str(offer['bonusTickets']),
230                h_align='center',
231                v_align='center',
232                maxwidth=self._width * 0.9,
233                scale=2.5,
234                color=(0.2, 1, 0.2),
235            )
236            self._price_text = ba.textwidget(
237                parent=self._root_widget,
238                position=(self._width * 0.5, 150),
239                size=(0, 0),
240                text=real_price,
241                h_align='center',
242                v_align='center',
243                maxwidth=self._width * 0.9,
244                scale=1.4,
245                color=(0.2, 1, 0.2),
246            )
247            # Total-value if they supplied it.
248            total_worth_item = offer.get('valueItem', None)
249            if total_worth_item is not None:
250                price = ba.internal.get_price(total_worth_item)
251                total_worth_price = (
252                    get_clean_price(price) if price is not None else None
253                )
254                if total_worth_price is not None:
255                    total_worth_text = ba.Lstr(
256                        resource='store.totalWorthText',
257                        subs=[('${TOTAL_WORTH}', total_worth_price)],
258                    )
259                    self._total_worth_text = ba.textwidget(
260                        parent=self._root_widget,
261                        text=total_worth_text,
262                        position=(self._width * 0.5, 210),
263                        scale=0.9,
264                        maxwidth=self._width * 0.7,
265                        size=(0, 0),
266                        h_align='center',
267                        v_align='center',
268                        shadow=1.0,
269                        flatness=1.0,
270                        color=(0.3, 1, 1),
271                    )
272
273        elif offer['item'] == 'pro_fullprice':
274            # for full-price pro we simply show full price
275            ba.textwidget(edit=display['price_widget'], text=real_price)
276            ba.buttonwidget(
277                edit=display['button'], on_activate_call=self._purchase
278            )
279        else:
280            # Show old/new prices otherwise (for pro sale).
281            ba.buttonwidget(
282                edit=display['button'], on_activate_call=self._purchase
283            )
284            ba.imagewidget(edit=display['price_slash_widget'], opacity=1.0)
285            ba.textwidget(
286                edit=display['price_widget_left'], text=original_price_str
287            )
288            ba.textwidget(
289                edit=display['price_widget_right'], text=new_price_str
290            )
291
292        # Add ticket button only if this is ticket-purchasable.
293        if isinstance(offer.get('price'), int):
294            self._get_tickets_button = ba.buttonwidget(
295                parent=self._root_widget,
296                position=(self._width - 125, self._height - 68),
297                size=(90, 55),
298                scale=1.0,
299                button_type='square',
300                color=(0.7, 0.5, 0.85),
301                textcolor=(0.2, 1, 0.2),
302                autoselect=True,
303                label=ba.Lstr(resource='getTicketsWindow.titleText'),
304                on_activate_call=self._on_get_more_tickets_press,
305            )
306
307            self._ticket_text_update_timer = ba.Timer(
308                1.0,
309                ba.WeakCall(self._update_tickets_text),
310                timetype=ba.TimeType.REAL,
311                repeat=True,
312            )
313            self._update_tickets_text()
314
315        self._update_timer = ba.Timer(
316            1.0,
317            ba.WeakCall(self._update),
318            timetype=ba.TimeType.REAL,
319            repeat=True,
320        )
321
322        self._cancel_button = ba.buttonwidget(
323            parent=self._root_widget,
324            position=(50, 40)
325            if self._is_bundle_sale
326            else (self._width * 0.5 - 75, 40),
327            size=(150, 60),
328            scale=1.0,
329            on_activate_call=self._cancel,
330            autoselect=True,
331            label=ba.Lstr(resource='noThanksText'),
332        )
333        self._cancel_countdown_text = ba.textwidget(
334            parent=self._root_widget,
335            text='',
336            position=(50 + 150 + 20, 40 + 27)
337            if self._is_bundle_sale
338            else (self._width * 0.5 - 75 + 150 + 20, 40 + 27),
339            scale=1.1,
340            size=(0, 0),
341            h_align='left',
342            v_align='center',
343            shadow=1.0,
344            flatness=1.0,
345            color=(0.6, 0.5, 0.5),
346        )
347        self._update_cancel_button_graphics()
348
349        if self._is_bundle_sale:
350            self._purchase_button = ba.buttonwidget(
351                parent=self._root_widget,
352                position=(self._width - 200, 40),
353                size=(150, 60),
354                scale=1.0,
355                on_activate_call=self._purchase,
356                autoselect=True,
357                label=ba.Lstr(resource='store.purchaseText'),
358            )
359
360        ba.containerwidget(
361            edit=self._root_widget,
362            cancel_button=self._cancel_button,
363            start_button=self._purchase_button
364            if self._is_bundle_sale
365            else None,
366            selected_child=self._purchase_button
367            if self._is_bundle_sale
368            else display['button'],
369        )
370
371    def _stop_flashing(self) -> None:
372        self._flashing_timer = None
373        ba.textwidget(edit=self._title_text, color=(0.3, 1, 0.3))
374
375    def _flash_cycle(self) -> None:
376        if not self._root_widget:
377            return
378        self._flash_on = not self._flash_on
379        ba.textwidget(
380            edit=self._title_text,
381            color=(0.3, 1, 0.3) if self._flash_on else (1, 0.5, 0),
382        )
383
384    def _update_cancel_button_graphics(self) -> None:
385        ba.buttonwidget(
386            edit=self._cancel_button,
387            color=(0.5, 0.5, 0.5)
388            if self._cancel_delay > 0
389            else (0.7, 0.4, 0.34),
390            textcolor=(0.5, 0.5, 0.5)
391            if self._cancel_delay > 0
392            else (0.9, 0.9, 1.0),
393        )
394        ba.textwidget(
395            edit=self._cancel_countdown_text,
396            text=str(self._cancel_delay) if self._cancel_delay > 0 else '',
397        )
398
399    def _update(self) -> None:
400
401        # If we've got seconds left on our countdown, update it.
402        if self._cancel_delay > 0:
403            self._cancel_delay = max(0, self._cancel_delay - 1)
404            self._update_cancel_button_graphics()
405
406        can_die = False
407
408        # We go away if we see that our target item is owned.
409        if self._offer_item == 'pro':
410            if ba.app.accounts_v1.have_pro():
411                can_die = True
412        else:
413            if ba.internal.get_purchased(self._offer_item):
414                can_die = True
415
416        if can_die:
417            self._transition_out('out_left')
418
419    def _transition_out(self, transition: str = 'out_left') -> None:
420        # Also clear any pending-special-offer we've stored at this point.
421        cfg = ba.app.config
422        if 'pendingSpecialOffer' in cfg:
423            del cfg['pendingSpecialOffer']
424            cfg.commit()
425
426        ba.containerwidget(edit=self._root_widget, transition=transition)
427
428    def _update_tickets_text(self) -> None:
429        from ba import SpecialChar
430
431        if not self._root_widget:
432            return
433        sval: str | ba.Lstr
434        if ba.internal.get_v1_account_state() == 'signed_in':
435            sval = ba.charstr(SpecialChar.TICKET) + str(
436                ba.internal.get_v1_account_ticket_count()
437            )
438        else:
439            sval = ba.Lstr(resource='getTicketsWindow.titleText')
440        ba.buttonwidget(edit=self._get_tickets_button, label=sval)
441
442    def _on_get_more_tickets_press(self) -> None:
443        from bastd.ui import account
444        from bastd.ui import getcurrency
445
446        if ba.internal.get_v1_account_state() != 'signed_in':
447            account.show_sign_in_prompt()
448            return
449        getcurrency.GetCurrencyWindow(modal=True).get_root_widget()
450
451    def _purchase(self) -> None:
452        from ba.internal import get_store_item_name_translated
453        from bastd.ui import getcurrency
454        from bastd.ui import confirm
455
456        if self._offer['item'] == 'pro':
457            ba.internal.purchase('pro_sale')
458        elif self._offer['item'] == 'pro_fullprice':
459            ba.internal.purchase('pro')
460        elif self._is_bundle_sale:
461            # With bundle sales, the price is the name of the IAP.
462            ba.internal.purchase(self._offer['price'])
463        else:
464            ticket_count: int | None
465            try:
466                ticket_count = ba.internal.get_v1_account_ticket_count()
467            except Exception:
468                ticket_count = None
469            if ticket_count is not None and ticket_count < self._offer['price']:
470                getcurrency.show_get_tickets_prompt()
471                ba.playsound(ba.getsound('error'))
472                return
473
474            def do_it() -> None:
475                ba.internal.in_game_purchase(
476                    'offer:' + str(self._offer['id']), self._offer['price']
477                )
478
479            ba.playsound(ba.getsound('swish'))
480            confirm.ConfirmWindow(
481                ba.Lstr(
482                    resource='store.purchaseConfirmText',
483                    subs=[
484                        (
485                            '${ITEM}',
486                            get_store_item_name_translated(self._offer['item']),
487                        )
488                    ],
489                ),
490                width=400,
491                height=120,
492                action=do_it,
493                ok_text=ba.Lstr(
494                    resource='store.purchaseText', fallback_resource='okText'
495                ),
496            )
497
498    def _cancel(self) -> None:
499        if self._cancel_delay > 0:
500            ba.playsound(ba.getsound('error'))
501            return
502        self._transition_out('out_right')
503
504
505def show_offer() -> bool:
506    """(internal)"""
507    try:
508        from bastd.ui import feedback
509
510        app = ba.app
511
512        # Space things out a bit so we don't hit the poor user with an ad and
513        # then an in-game offer.
514        has_been_long_enough_since_ad = True
515        if app.ads.last_ad_completion_time is not None and (
516            ba.time(ba.TimeType.REAL) - app.ads.last_ad_completion_time < 30.0
517        ):
518            has_been_long_enough_since_ad = False
519
520        if app.special_offer is not None and has_been_long_enough_since_ad:
521
522            # Special case: for pro offers, store this in our prefs so we
523            # can re-show it if the user kills us (set phasers to 'NAG'!!!).
524            if app.special_offer.get('item') == 'pro_fullprice':
525                cfg = app.config
526                cfg['pendingSpecialOffer'] = {
527                    'a': ba.internal.get_public_login_id(),
528                    'o': app.special_offer,
529                }
530                cfg.commit()
531
532            with ba.Context('ui'):
533                if app.special_offer['item'] == 'rating':
534                    feedback.ask_for_rating()
535                else:
536                    SpecialOfferWindow(app.special_offer)
537
538            app.special_offer = None
539            return True
540    except Exception:
541        ba.print_exception('Error showing offer.')
542
543    return False
class SpecialOfferWindow(ba.ui.Window):
 18class SpecialOfferWindow(ba.Window):
 19    """Window for presenting sales/etc."""
 20
 21    def __init__(self, offer: dict[str, Any], transition: str = 'in_right'):
 22        # pylint: disable=too-many-statements
 23        # pylint: disable=too-many-branches
 24        # pylint: disable=too-many-locals
 25        from ba.internal import get_store_item_display_size, get_clean_price
 26        from ba import SpecialChar
 27        from bastd.ui.store import item as storeitemui
 28
 29        self._cancel_delay = offer.get('cancelDelay', 0)
 30
 31        # First thing: if we're offering pro or an IAP, see if we have a
 32        # price for it.
 33        # If not, abort and go into zombie mode (the user should never see
 34        # us that way).
 35
 36        real_price: str | None
 37
 38        # Misnomer: 'pro' actually means offer 'pro_sale'.
 39        if offer['item'] in ['pro', 'pro_fullprice']:
 40            real_price = ba.internal.get_price(
 41                'pro' if offer['item'] == 'pro_fullprice' else 'pro_sale'
 42            )
 43            if real_price is None and ba.app.debug_build:
 44                print('NOTE: Faking prices for debug build.')
 45                real_price = '$1.23'
 46            zombie = real_price is None
 47        elif isinstance(offer['price'], str):
 48            # (a string price implies IAP id)
 49            real_price = ba.internal.get_price(offer['price'])
 50            if real_price is None and ba.app.debug_build:
 51                print('NOTE: Faking price for debug build.')
 52                real_price = '$1.23'
 53            zombie = real_price is None
 54        else:
 55            real_price = None
 56            zombie = False
 57        if real_price is None:
 58            real_price = '?'
 59
 60        if offer['item'] in ['pro', 'pro_fullprice']:
 61            self._offer_item = 'pro'
 62        else:
 63            self._offer_item = offer['item']
 64
 65        # If we wanted a real price but didn't find one, go zombie.
 66        if zombie:
 67            return
 68
 69        # This can pop up suddenly, so lets block input for 1 second.
 70        ba.internal.lock_all_input()
 71        ba.timer(1.0, ba.internal.unlock_all_input, timetype=ba.TimeType.REAL)
 72        ba.playsound(ba.getsound('ding'))
 73        ba.timer(
 74            0.3,
 75            lambda: ba.playsound(ba.getsound('ooh')),
 76            timetype=ba.TimeType.REAL,
 77        )
 78        self._offer = copy.deepcopy(offer)
 79        self._width = 580
 80        self._height = 590
 81        uiscale = ba.app.ui.uiscale
 82        super().__init__(
 83            root_widget=ba.containerwidget(
 84                size=(self._width, self._height),
 85                transition=transition,
 86                scale=(
 87                    1.2
 88                    if uiscale is ba.UIScale.SMALL
 89                    else 1.15
 90                    if uiscale is ba.UIScale.MEDIUM
 91                    else 1.0
 92                ),
 93                stack_offset=(0, -15)
 94                if uiscale is ba.UIScale.SMALL
 95                else (0, 0),
 96            )
 97        )
 98        self._is_bundle_sale = False
 99        try:
100            if offer['item'] in ['pro', 'pro_fullprice']:
101                original_price_str = ba.internal.get_price('pro')
102                if original_price_str is None:
103                    original_price_str = '?'
104                new_price_str = ba.internal.get_price('pro_sale')
105                if new_price_str is None:
106                    new_price_str = '?'
107                percent_off_text = ''
108            else:
109                # If the offer includes bonus tickets, it's a bundle-sale.
110                if (
111                    'bonusTickets' in offer
112                    and offer['bonusTickets'] is not None
113                ):
114                    self._is_bundle_sale = True
115                original_price = ba.internal.get_v1_account_misc_read_val(
116                    'price.' + self._offer_item, 9999
117                )
118
119                # For pure ticket prices we can show a percent-off.
120                if isinstance(offer['price'], int):
121                    new_price = offer['price']
122                    tchar = ba.charstr(SpecialChar.TICKET)
123                    original_price_str = tchar + str(original_price)
124                    new_price_str = tchar + str(new_price)
125                    percent_off = int(
126                        round(
127                            100.0 - (float(new_price) / original_price) * 100.0
128                        )
129                    )
130                    percent_off_text = ' ' + ba.Lstr(
131                        resource='store.salePercentText'
132                    ).evaluate().replace('${PERCENT}', str(percent_off))
133                else:
134                    original_price_str = new_price_str = '?'
135                    percent_off_text = ''
136
137        except Exception:
138            print(f'Offer: {offer}')
139            ba.print_exception('Error setting up special-offer')
140            original_price_str = new_price_str = '?'
141            percent_off_text = ''
142
143        # If its a bundle sale, change the title.
144        if self._is_bundle_sale:
145            sale_text = ba.Lstr(
146                resource='store.saleBundleText',
147                fallback_resource='store.saleText',
148            ).evaluate()
149        else:
150            # For full pro we say 'Upgrade?' since its not really a sale.
151            if offer['item'] == 'pro_fullprice':
152                sale_text = ba.Lstr(
153                    resource='store.upgradeQuestionText',
154                    fallback_resource='store.saleExclaimText',
155                ).evaluate()
156            else:
157                sale_text = ba.Lstr(
158                    resource='store.saleExclaimText',
159                    fallback_resource='store.saleText',
160                ).evaluate()
161
162        self._title_text = ba.textwidget(
163            parent=self._root_widget,
164            position=(self._width * 0.5, self._height - 40),
165            size=(0, 0),
166            text=sale_text
167            + (
168                (' ' + ba.Lstr(resource='store.oneTimeOnlyText').evaluate())
169                if self._offer['oneTimeOnly']
170                else ''
171            )
172            + percent_off_text,
173            h_align='center',
174            v_align='center',
175            maxwidth=self._width * 0.9 - 220,
176            scale=1.4,
177            color=(0.3, 1, 0.3),
178        )
179
180        self._flash_on = False
181        self._flashing_timer: ba.Timer | None = ba.Timer(
182            0.05,
183            ba.WeakCall(self._flash_cycle),
184            repeat=True,
185            timetype=ba.TimeType.REAL,
186        )
187        ba.timer(
188            0.6, ba.WeakCall(self._stop_flashing), timetype=ba.TimeType.REAL
189        )
190
191        size = get_store_item_display_size(self._offer_item)
192        display: dict[str, Any] = {}
193        storeitemui.instantiate_store_item_display(
194            self._offer_item,
195            display,
196            parent_widget=self._root_widget,
197            b_pos=(
198                self._width * 0.5
199                - size[0] * 0.5
200                + 10
201                - ((size[0] * 0.5 + 30) if self._is_bundle_sale else 0),
202                self._height * 0.5
203                - size[1] * 0.5
204                + 20
205                + (20 if self._is_bundle_sale else 0),
206            ),
207            b_width=size[0],
208            b_height=size[1],
209            button=not self._is_bundle_sale,
210        )
211
212        # Wire up the parts we need.
213        if self._is_bundle_sale:
214            self._plus_text = ba.textwidget(
215                parent=self._root_widget,
216                position=(self._width * 0.5, self._height * 0.5 + 50),
217                size=(0, 0),
218                text='+',
219                h_align='center',
220                v_align='center',
221                maxwidth=self._width * 0.9,
222                scale=1.4,
223                color=(0.5, 0.5, 0.5),
224            )
225            self._plus_tickets = ba.textwidget(
226                parent=self._root_widget,
227                position=(self._width * 0.5 + 120, self._height * 0.5 + 50),
228                size=(0, 0),
229                text=ba.charstr(SpecialChar.TICKET_BACKING)
230                + str(offer['bonusTickets']),
231                h_align='center',
232                v_align='center',
233                maxwidth=self._width * 0.9,
234                scale=2.5,
235                color=(0.2, 1, 0.2),
236            )
237            self._price_text = ba.textwidget(
238                parent=self._root_widget,
239                position=(self._width * 0.5, 150),
240                size=(0, 0),
241                text=real_price,
242                h_align='center',
243                v_align='center',
244                maxwidth=self._width * 0.9,
245                scale=1.4,
246                color=(0.2, 1, 0.2),
247            )
248            # Total-value if they supplied it.
249            total_worth_item = offer.get('valueItem', None)
250            if total_worth_item is not None:
251                price = ba.internal.get_price(total_worth_item)
252                total_worth_price = (
253                    get_clean_price(price) if price is not None else None
254                )
255                if total_worth_price is not None:
256                    total_worth_text = ba.Lstr(
257                        resource='store.totalWorthText',
258                        subs=[('${TOTAL_WORTH}', total_worth_price)],
259                    )
260                    self._total_worth_text = ba.textwidget(
261                        parent=self._root_widget,
262                        text=total_worth_text,
263                        position=(self._width * 0.5, 210),
264                        scale=0.9,
265                        maxwidth=self._width * 0.7,
266                        size=(0, 0),
267                        h_align='center',
268                        v_align='center',
269                        shadow=1.0,
270                        flatness=1.0,
271                        color=(0.3, 1, 1),
272                    )
273
274        elif offer['item'] == 'pro_fullprice':
275            # for full-price pro we simply show full price
276            ba.textwidget(edit=display['price_widget'], text=real_price)
277            ba.buttonwidget(
278                edit=display['button'], on_activate_call=self._purchase
279            )
280        else:
281            # Show old/new prices otherwise (for pro sale).
282            ba.buttonwidget(
283                edit=display['button'], on_activate_call=self._purchase
284            )
285            ba.imagewidget(edit=display['price_slash_widget'], opacity=1.0)
286            ba.textwidget(
287                edit=display['price_widget_left'], text=original_price_str
288            )
289            ba.textwidget(
290                edit=display['price_widget_right'], text=new_price_str
291            )
292
293        # Add ticket button only if this is ticket-purchasable.
294        if isinstance(offer.get('price'), int):
295            self._get_tickets_button = ba.buttonwidget(
296                parent=self._root_widget,
297                position=(self._width - 125, self._height - 68),
298                size=(90, 55),
299                scale=1.0,
300                button_type='square',
301                color=(0.7, 0.5, 0.85),
302                textcolor=(0.2, 1, 0.2),
303                autoselect=True,
304                label=ba.Lstr(resource='getTicketsWindow.titleText'),
305                on_activate_call=self._on_get_more_tickets_press,
306            )
307
308            self._ticket_text_update_timer = ba.Timer(
309                1.0,
310                ba.WeakCall(self._update_tickets_text),
311                timetype=ba.TimeType.REAL,
312                repeat=True,
313            )
314            self._update_tickets_text()
315
316        self._update_timer = ba.Timer(
317            1.0,
318            ba.WeakCall(self._update),
319            timetype=ba.TimeType.REAL,
320            repeat=True,
321        )
322
323        self._cancel_button = ba.buttonwidget(
324            parent=self._root_widget,
325            position=(50, 40)
326            if self._is_bundle_sale
327            else (self._width * 0.5 - 75, 40),
328            size=(150, 60),
329            scale=1.0,
330            on_activate_call=self._cancel,
331            autoselect=True,
332            label=ba.Lstr(resource='noThanksText'),
333        )
334        self._cancel_countdown_text = ba.textwidget(
335            parent=self._root_widget,
336            text='',
337            position=(50 + 150 + 20, 40 + 27)
338            if self._is_bundle_sale
339            else (self._width * 0.5 - 75 + 150 + 20, 40 + 27),
340            scale=1.1,
341            size=(0, 0),
342            h_align='left',
343            v_align='center',
344            shadow=1.0,
345            flatness=1.0,
346            color=(0.6, 0.5, 0.5),
347        )
348        self._update_cancel_button_graphics()
349
350        if self._is_bundle_sale:
351            self._purchase_button = ba.buttonwidget(
352                parent=self._root_widget,
353                position=(self._width - 200, 40),
354                size=(150, 60),
355                scale=1.0,
356                on_activate_call=self._purchase,
357                autoselect=True,
358                label=ba.Lstr(resource='store.purchaseText'),
359            )
360
361        ba.containerwidget(
362            edit=self._root_widget,
363            cancel_button=self._cancel_button,
364            start_button=self._purchase_button
365            if self._is_bundle_sale
366            else None,
367            selected_child=self._purchase_button
368            if self._is_bundle_sale
369            else display['button'],
370        )
371
372    def _stop_flashing(self) -> None:
373        self._flashing_timer = None
374        ba.textwidget(edit=self._title_text, color=(0.3, 1, 0.3))
375
376    def _flash_cycle(self) -> None:
377        if not self._root_widget:
378            return
379        self._flash_on = not self._flash_on
380        ba.textwidget(
381            edit=self._title_text,
382            color=(0.3, 1, 0.3) if self._flash_on else (1, 0.5, 0),
383        )
384
385    def _update_cancel_button_graphics(self) -> None:
386        ba.buttonwidget(
387            edit=self._cancel_button,
388            color=(0.5, 0.5, 0.5)
389            if self._cancel_delay > 0
390            else (0.7, 0.4, 0.34),
391            textcolor=(0.5, 0.5, 0.5)
392            if self._cancel_delay > 0
393            else (0.9, 0.9, 1.0),
394        )
395        ba.textwidget(
396            edit=self._cancel_countdown_text,
397            text=str(self._cancel_delay) if self._cancel_delay > 0 else '',
398        )
399
400    def _update(self) -> None:
401
402        # If we've got seconds left on our countdown, update it.
403        if self._cancel_delay > 0:
404            self._cancel_delay = max(0, self._cancel_delay - 1)
405            self._update_cancel_button_graphics()
406
407        can_die = False
408
409        # We go away if we see that our target item is owned.
410        if self._offer_item == 'pro':
411            if ba.app.accounts_v1.have_pro():
412                can_die = True
413        else:
414            if ba.internal.get_purchased(self._offer_item):
415                can_die = True
416
417        if can_die:
418            self._transition_out('out_left')
419
420    def _transition_out(self, transition: str = 'out_left') -> None:
421        # Also clear any pending-special-offer we've stored at this point.
422        cfg = ba.app.config
423        if 'pendingSpecialOffer' in cfg:
424            del cfg['pendingSpecialOffer']
425            cfg.commit()
426
427        ba.containerwidget(edit=self._root_widget, transition=transition)
428
429    def _update_tickets_text(self) -> None:
430        from ba import SpecialChar
431
432        if not self._root_widget:
433            return
434        sval: str | ba.Lstr
435        if ba.internal.get_v1_account_state() == 'signed_in':
436            sval = ba.charstr(SpecialChar.TICKET) + str(
437                ba.internal.get_v1_account_ticket_count()
438            )
439        else:
440            sval = ba.Lstr(resource='getTicketsWindow.titleText')
441        ba.buttonwidget(edit=self._get_tickets_button, label=sval)
442
443    def _on_get_more_tickets_press(self) -> None:
444        from bastd.ui import account
445        from bastd.ui import getcurrency
446
447        if ba.internal.get_v1_account_state() != 'signed_in':
448            account.show_sign_in_prompt()
449            return
450        getcurrency.GetCurrencyWindow(modal=True).get_root_widget()
451
452    def _purchase(self) -> None:
453        from ba.internal import get_store_item_name_translated
454        from bastd.ui import getcurrency
455        from bastd.ui import confirm
456
457        if self._offer['item'] == 'pro':
458            ba.internal.purchase('pro_sale')
459        elif self._offer['item'] == 'pro_fullprice':
460            ba.internal.purchase('pro')
461        elif self._is_bundle_sale:
462            # With bundle sales, the price is the name of the IAP.
463            ba.internal.purchase(self._offer['price'])
464        else:
465            ticket_count: int | None
466            try:
467                ticket_count = ba.internal.get_v1_account_ticket_count()
468            except Exception:
469                ticket_count = None
470            if ticket_count is not None and ticket_count < self._offer['price']:
471                getcurrency.show_get_tickets_prompt()
472                ba.playsound(ba.getsound('error'))
473                return
474
475            def do_it() -> None:
476                ba.internal.in_game_purchase(
477                    'offer:' + str(self._offer['id']), self._offer['price']
478                )
479
480            ba.playsound(ba.getsound('swish'))
481            confirm.ConfirmWindow(
482                ba.Lstr(
483                    resource='store.purchaseConfirmText',
484                    subs=[
485                        (
486                            '${ITEM}',
487                            get_store_item_name_translated(self._offer['item']),
488                        )
489                    ],
490                ),
491                width=400,
492                height=120,
493                action=do_it,
494                ok_text=ba.Lstr(
495                    resource='store.purchaseText', fallback_resource='okText'
496                ),
497            )
498
499    def _cancel(self) -> None:
500        if self._cancel_delay > 0:
501            ba.playsound(ba.getsound('error'))
502            return
503        self._transition_out('out_right')

Window for presenting sales/etc.

SpecialOfferWindow(offer: dict[str, typing.Any], transition: str = 'in_right')
 21    def __init__(self, offer: dict[str, Any], transition: str = 'in_right'):
 22        # pylint: disable=too-many-statements
 23        # pylint: disable=too-many-branches
 24        # pylint: disable=too-many-locals
 25        from ba.internal import get_store_item_display_size, get_clean_price
 26        from ba import SpecialChar
 27        from bastd.ui.store import item as storeitemui
 28
 29        self._cancel_delay = offer.get('cancelDelay', 0)
 30
 31        # First thing: if we're offering pro or an IAP, see if we have a
 32        # price for it.
 33        # If not, abort and go into zombie mode (the user should never see
 34        # us that way).
 35
 36        real_price: str | None
 37
 38        # Misnomer: 'pro' actually means offer 'pro_sale'.
 39        if offer['item'] in ['pro', 'pro_fullprice']:
 40            real_price = ba.internal.get_price(
 41                'pro' if offer['item'] == 'pro_fullprice' else 'pro_sale'
 42            )
 43            if real_price is None and ba.app.debug_build:
 44                print('NOTE: Faking prices for debug build.')
 45                real_price = '$1.23'
 46            zombie = real_price is None
 47        elif isinstance(offer['price'], str):
 48            # (a string price implies IAP id)
 49            real_price = ba.internal.get_price(offer['price'])
 50            if real_price is None and ba.app.debug_build:
 51                print('NOTE: Faking price for debug build.')
 52                real_price = '$1.23'
 53            zombie = real_price is None
 54        else:
 55            real_price = None
 56            zombie = False
 57        if real_price is None:
 58            real_price = '?'
 59
 60        if offer['item'] in ['pro', 'pro_fullprice']:
 61            self._offer_item = 'pro'
 62        else:
 63            self._offer_item = offer['item']
 64
 65        # If we wanted a real price but didn't find one, go zombie.
 66        if zombie:
 67            return
 68
 69        # This can pop up suddenly, so lets block input for 1 second.
 70        ba.internal.lock_all_input()
 71        ba.timer(1.0, ba.internal.unlock_all_input, timetype=ba.TimeType.REAL)
 72        ba.playsound(ba.getsound('ding'))
 73        ba.timer(
 74            0.3,
 75            lambda: ba.playsound(ba.getsound('ooh')),
 76            timetype=ba.TimeType.REAL,
 77        )
 78        self._offer = copy.deepcopy(offer)
 79        self._width = 580
 80        self._height = 590
 81        uiscale = ba.app.ui.uiscale
 82        super().__init__(
 83            root_widget=ba.containerwidget(
 84                size=(self._width, self._height),
 85                transition=transition,
 86                scale=(
 87                    1.2
 88                    if uiscale is ba.UIScale.SMALL
 89                    else 1.15
 90                    if uiscale is ba.UIScale.MEDIUM
 91                    else 1.0
 92                ),
 93                stack_offset=(0, -15)
 94                if uiscale is ba.UIScale.SMALL
 95                else (0, 0),
 96            )
 97        )
 98        self._is_bundle_sale = False
 99        try:
100            if offer['item'] in ['pro', 'pro_fullprice']:
101                original_price_str = ba.internal.get_price('pro')
102                if original_price_str is None:
103                    original_price_str = '?'
104                new_price_str = ba.internal.get_price('pro_sale')
105                if new_price_str is None:
106                    new_price_str = '?'
107                percent_off_text = ''
108            else:
109                # If the offer includes bonus tickets, it's a bundle-sale.
110                if (
111                    'bonusTickets' in offer
112                    and offer['bonusTickets'] is not None
113                ):
114                    self._is_bundle_sale = True
115                original_price = ba.internal.get_v1_account_misc_read_val(
116                    'price.' + self._offer_item, 9999
117                )
118
119                # For pure ticket prices we can show a percent-off.
120                if isinstance(offer['price'], int):
121                    new_price = offer['price']
122                    tchar = ba.charstr(SpecialChar.TICKET)
123                    original_price_str = tchar + str(original_price)
124                    new_price_str = tchar + str(new_price)
125                    percent_off = int(
126                        round(
127                            100.0 - (float(new_price) / original_price) * 100.0
128                        )
129                    )
130                    percent_off_text = ' ' + ba.Lstr(
131                        resource='store.salePercentText'
132                    ).evaluate().replace('${PERCENT}', str(percent_off))
133                else:
134                    original_price_str = new_price_str = '?'
135                    percent_off_text = ''
136
137        except Exception:
138            print(f'Offer: {offer}')
139            ba.print_exception('Error setting up special-offer')
140            original_price_str = new_price_str = '?'
141            percent_off_text = ''
142
143        # If its a bundle sale, change the title.
144        if self._is_bundle_sale:
145            sale_text = ba.Lstr(
146                resource='store.saleBundleText',
147                fallback_resource='store.saleText',
148            ).evaluate()
149        else:
150            # For full pro we say 'Upgrade?' since its not really a sale.
151            if offer['item'] == 'pro_fullprice':
152                sale_text = ba.Lstr(
153                    resource='store.upgradeQuestionText',
154                    fallback_resource='store.saleExclaimText',
155                ).evaluate()
156            else:
157                sale_text = ba.Lstr(
158                    resource='store.saleExclaimText',
159                    fallback_resource='store.saleText',
160                ).evaluate()
161
162        self._title_text = ba.textwidget(
163            parent=self._root_widget,
164            position=(self._width * 0.5, self._height - 40),
165            size=(0, 0),
166            text=sale_text
167            + (
168                (' ' + ba.Lstr(resource='store.oneTimeOnlyText').evaluate())
169                if self._offer['oneTimeOnly']
170                else ''
171            )
172            + percent_off_text,
173            h_align='center',
174            v_align='center',
175            maxwidth=self._width * 0.9 - 220,
176            scale=1.4,
177            color=(0.3, 1, 0.3),
178        )
179
180        self._flash_on = False
181        self._flashing_timer: ba.Timer | None = ba.Timer(
182            0.05,
183            ba.WeakCall(self._flash_cycle),
184            repeat=True,
185            timetype=ba.TimeType.REAL,
186        )
187        ba.timer(
188            0.6, ba.WeakCall(self._stop_flashing), timetype=ba.TimeType.REAL
189        )
190
191        size = get_store_item_display_size(self._offer_item)
192        display: dict[str, Any] = {}
193        storeitemui.instantiate_store_item_display(
194            self._offer_item,
195            display,
196            parent_widget=self._root_widget,
197            b_pos=(
198                self._width * 0.5
199                - size[0] * 0.5
200                + 10
201                - ((size[0] * 0.5 + 30) if self._is_bundle_sale else 0),
202                self._height * 0.5
203                - size[1] * 0.5
204                + 20
205                + (20 if self._is_bundle_sale else 0),
206            ),
207            b_width=size[0],
208            b_height=size[1],
209            button=not self._is_bundle_sale,
210        )
211
212        # Wire up the parts we need.
213        if self._is_bundle_sale:
214            self._plus_text = ba.textwidget(
215                parent=self._root_widget,
216                position=(self._width * 0.5, self._height * 0.5 + 50),
217                size=(0, 0),
218                text='+',
219                h_align='center',
220                v_align='center',
221                maxwidth=self._width * 0.9,
222                scale=1.4,
223                color=(0.5, 0.5, 0.5),
224            )
225            self._plus_tickets = ba.textwidget(
226                parent=self._root_widget,
227                position=(self._width * 0.5 + 120, self._height * 0.5 + 50),
228                size=(0, 0),
229                text=ba.charstr(SpecialChar.TICKET_BACKING)
230                + str(offer['bonusTickets']),
231                h_align='center',
232                v_align='center',
233                maxwidth=self._width * 0.9,
234                scale=2.5,
235                color=(0.2, 1, 0.2),
236            )
237            self._price_text = ba.textwidget(
238                parent=self._root_widget,
239                position=(self._width * 0.5, 150),
240                size=(0, 0),
241                text=real_price,
242                h_align='center',
243                v_align='center',
244                maxwidth=self._width * 0.9,
245                scale=1.4,
246                color=(0.2, 1, 0.2),
247            )
248            # Total-value if they supplied it.
249            total_worth_item = offer.get('valueItem', None)
250            if total_worth_item is not None:
251                price = ba.internal.get_price(total_worth_item)
252                total_worth_price = (
253                    get_clean_price(price) if price is not None else None
254                )
255                if total_worth_price is not None:
256                    total_worth_text = ba.Lstr(
257                        resource='store.totalWorthText',
258                        subs=[('${TOTAL_WORTH}', total_worth_price)],
259                    )
260                    self._total_worth_text = ba.textwidget(
261                        parent=self._root_widget,
262                        text=total_worth_text,
263                        position=(self._width * 0.5, 210),
264                        scale=0.9,
265                        maxwidth=self._width * 0.7,
266                        size=(0, 0),
267                        h_align='center',
268                        v_align='center',
269                        shadow=1.0,
270                        flatness=1.0,
271                        color=(0.3, 1, 1),
272                    )
273
274        elif offer['item'] == 'pro_fullprice':
275            # for full-price pro we simply show full price
276            ba.textwidget(edit=display['price_widget'], text=real_price)
277            ba.buttonwidget(
278                edit=display['button'], on_activate_call=self._purchase
279            )
280        else:
281            # Show old/new prices otherwise (for pro sale).
282            ba.buttonwidget(
283                edit=display['button'], on_activate_call=self._purchase
284            )
285            ba.imagewidget(edit=display['price_slash_widget'], opacity=1.0)
286            ba.textwidget(
287                edit=display['price_widget_left'], text=original_price_str
288            )
289            ba.textwidget(
290                edit=display['price_widget_right'], text=new_price_str
291            )
292
293        # Add ticket button only if this is ticket-purchasable.
294        if isinstance(offer.get('price'), int):
295            self._get_tickets_button = ba.buttonwidget(
296                parent=self._root_widget,
297                position=(self._width - 125, self._height - 68),
298                size=(90, 55),
299                scale=1.0,
300                button_type='square',
301                color=(0.7, 0.5, 0.85),
302                textcolor=(0.2, 1, 0.2),
303                autoselect=True,
304                label=ba.Lstr(resource='getTicketsWindow.titleText'),
305                on_activate_call=self._on_get_more_tickets_press,
306            )
307
308            self._ticket_text_update_timer = ba.Timer(
309                1.0,
310                ba.WeakCall(self._update_tickets_text),
311                timetype=ba.TimeType.REAL,
312                repeat=True,
313            )
314            self._update_tickets_text()
315
316        self._update_timer = ba.Timer(
317            1.0,
318            ba.WeakCall(self._update),
319            timetype=ba.TimeType.REAL,
320            repeat=True,
321        )
322
323        self._cancel_button = ba.buttonwidget(
324            parent=self._root_widget,
325            position=(50, 40)
326            if self._is_bundle_sale
327            else (self._width * 0.5 - 75, 40),
328            size=(150, 60),
329            scale=1.0,
330            on_activate_call=self._cancel,
331            autoselect=True,
332            label=ba.Lstr(resource='noThanksText'),
333        )
334        self._cancel_countdown_text = ba.textwidget(
335            parent=self._root_widget,
336            text='',
337            position=(50 + 150 + 20, 40 + 27)
338            if self._is_bundle_sale
339            else (self._width * 0.5 - 75 + 150 + 20, 40 + 27),
340            scale=1.1,
341            size=(0, 0),
342            h_align='left',
343            v_align='center',
344            shadow=1.0,
345            flatness=1.0,
346            color=(0.6, 0.5, 0.5),
347        )
348        self._update_cancel_button_graphics()
349
350        if self._is_bundle_sale:
351            self._purchase_button = ba.buttonwidget(
352                parent=self._root_widget,
353                position=(self._width - 200, 40),
354                size=(150, 60),
355                scale=1.0,
356                on_activate_call=self._purchase,
357                autoselect=True,
358                label=ba.Lstr(resource='store.purchaseText'),
359            )
360
361        ba.containerwidget(
362            edit=self._root_widget,
363            cancel_button=self._cancel_button,
364            start_button=self._purchase_button
365            if self._is_bundle_sale
366            else None,
367            selected_child=self._purchase_button
368            if self._is_bundle_sale
369            else display['button'],
370        )
Inherited Members
ba.ui.Window
get_root_widget