# Released under the MIT License. See LICENSE for details. # """UI for presenting sales/etc.""" from __future__ import annotations import copy from typing import TYPE_CHECKING import ba import ba.internal if TYPE_CHECKING: from typing import Any class SpecialOfferWindow(ba.Window): """Window for presenting sales/etc.""" def __init__(self, offer: dict[str, Any], transition: str = 'in_right'): # pylint: disable=too-many-statements # pylint: disable=too-many-branches # pylint: disable=too-many-locals from ba.internal import get_store_item_display_size, get_clean_price from ba import SpecialChar from bastd.ui.store import item as storeitemui self._cancel_delay = offer.get('cancelDelay', 0) # First thing: if we're offering pro or an IAP, see if we have a # price for it. # If not, abort and go into zombie mode (the user should never see # us that way). real_price: str | None # Misnomer: 'pro' actually means offer 'pro_sale'. if offer['item'] in ['pro', 'pro_fullprice']: real_price = ba.internal.get_price( 'pro' if offer['item'] == 'pro_fullprice' else 'pro_sale' ) if real_price is None and ba.app.debug_build: print('NOTE: Faking prices for debug build.') real_price = '$1.23' zombie = real_price is None elif isinstance(offer['price'], str): # (a string price implies IAP id) real_price = ba.internal.get_price(offer['price']) if real_price is None and ba.app.debug_build: print('NOTE: Faking price for debug build.') real_price = '$1.23' zombie = real_price is None else: real_price = None zombie = False if real_price is None: real_price = '?' if offer['item'] in ['pro', 'pro_fullprice']: self._offer_item = 'pro' else: self._offer_item = offer['item'] # If we wanted a real price but didn't find one, go zombie. if zombie: return # This can pop up suddenly, so lets block input for 1 second. ba.internal.lock_all_input() ba.timer(1.0, ba.internal.unlock_all_input, timetype=ba.TimeType.REAL) ba.playsound(ba.getsound('ding')) ba.timer( 0.3, lambda: ba.playsound(ba.getsound('ooh')), timetype=ba.TimeType.REAL, ) self._offer = copy.deepcopy(offer) self._width = 580 self._height = 590 uiscale = ba.app.ui.uiscale super().__init__( root_widget=ba.containerwidget( size=(self._width, self._height), transition=transition, scale=( 1.2 if uiscale is ba.UIScale.SMALL else 1.15 if uiscale is ba.UIScale.MEDIUM else 1.0 ), stack_offset=(0, -15) if uiscale is ba.UIScale.SMALL else (0, 0), ) ) self._is_bundle_sale = False try: if offer['item'] in ['pro', 'pro_fullprice']: original_price_str = ba.internal.get_price('pro') if original_price_str is None: original_price_str = '?' new_price_str = ba.internal.get_price('pro_sale') if new_price_str is None: new_price_str = '?' percent_off_text = '' else: # If the offer includes bonus tickets, it's a bundle-sale. if ( 'bonusTickets' in offer and offer['bonusTickets'] is not None ): self._is_bundle_sale = True original_price = ba.internal.get_v1_account_misc_read_val( 'price.' + self._offer_item, 9999 ) # For pure ticket prices we can show a percent-off. if isinstance(offer['price'], int): new_price = offer['price'] tchar = ba.charstr(SpecialChar.TICKET) original_price_str = tchar + str(original_price) new_price_str = tchar + str(new_price) percent_off = int( round( 100.0 - (float(new_price) / original_price) * 100.0 ) ) percent_off_text = ' ' + ba.Lstr( resource='store.salePercentText' ).evaluate().replace('${PERCENT}', str(percent_off)) else: original_price_str = new_price_str = '?' percent_off_text = '' except Exception: print(f'Offer: {offer}') ba.print_exception('Error setting up special-offer') original_price_str = new_price_str = '?' percent_off_text = '' # If its a bundle sale, change the title. if self._is_bundle_sale: sale_text = ba.Lstr( resource='store.saleBundleText', fallback_resource='store.saleText', ).evaluate() else: # For full pro we say 'Upgrade?' since its not really a sale. if offer['item'] == 'pro_fullprice': sale_text = ba.Lstr( resource='store.upgradeQuestionText', fallback_resource='store.saleExclaimText', ).evaluate() else: sale_text = ba.Lstr( resource='store.saleExclaimText', fallback_resource='store.saleText', ).evaluate() self._title_text = ba.textwidget( parent=self._root_widget, position=(self._width * 0.5, self._height - 40), size=(0, 0), text=sale_text + ( (' ' + ba.Lstr(resource='store.oneTimeOnlyText').evaluate()) if self._offer['oneTimeOnly'] else '' ) + percent_off_text, h_align='center', v_align='center', maxwidth=self._width * 0.9 - 220, scale=1.4, color=(0.3, 1, 0.3), ) self._flash_on = False self._flashing_timer: ba.Timer | None = ba.Timer( 0.05, ba.WeakCall(self._flash_cycle), repeat=True, timetype=ba.TimeType.REAL, ) ba.timer( 0.6, ba.WeakCall(self._stop_flashing), timetype=ba.TimeType.REAL ) size = get_store_item_display_size(self._offer_item) display: dict[str, Any] = {} storeitemui.instantiate_store_item_display( self._offer_item, display, parent_widget=self._root_widget, b_pos=( self._width * 0.5 - size[0] * 0.5 + 10 - ((size[0] * 0.5 + 30) if self._is_bundle_sale else 0), self._height * 0.5 - size[1] * 0.5 + 20 + (20 if self._is_bundle_sale else 0), ), b_width=size[0], b_height=size[1], button=not self._is_bundle_sale, ) # Wire up the parts we need. if self._is_bundle_sale: self._plus_text = ba.textwidget( parent=self._root_widget, position=(self._width * 0.5, self._height * 0.5 + 50), size=(0, 0), text='+', h_align='center', v_align='center', maxwidth=self._width * 0.9, scale=1.4, color=(0.5, 0.5, 0.5), ) self._plus_tickets = ba.textwidget( parent=self._root_widget, position=(self._width * 0.5 + 120, self._height * 0.5 + 50), size=(0, 0), text=ba.charstr(SpecialChar.TICKET_BACKING) + str(offer['bonusTickets']), h_align='center', v_align='center', maxwidth=self._width * 0.9, scale=2.5, color=(0.2, 1, 0.2), ) self._price_text = ba.textwidget( parent=self._root_widget, position=(self._width * 0.5, 150), size=(0, 0), text=real_price, h_align='center', v_align='center', maxwidth=self._width * 0.9, scale=1.4, color=(0.2, 1, 0.2), ) # Total-value if they supplied it. total_worth_item = offer.get('valueItem', None) if total_worth_item is not None: price = ba.internal.get_price(total_worth_item) total_worth_price = ( get_clean_price(price) if price is not None else None ) if total_worth_price is not None: total_worth_text = ba.Lstr( resource='store.totalWorthText', subs=[('${TOTAL_WORTH}', total_worth_price)], ) self._total_worth_text = ba.textwidget( parent=self._root_widget, text=total_worth_text, position=(self._width * 0.5, 210), scale=0.9, maxwidth=self._width * 0.7, size=(0, 0), h_align='center', v_align='center', shadow=1.0, flatness=1.0, color=(0.3, 1, 1), ) elif offer['item'] == 'pro_fullprice': # for full-price pro we simply show full price ba.textwidget(edit=display['price_widget'], text=real_price) ba.buttonwidget( edit=display['button'], on_activate_call=self._purchase ) else: # Show old/new prices otherwise (for pro sale). ba.buttonwidget( edit=display['button'], on_activate_call=self._purchase ) ba.imagewidget(edit=display['price_slash_widget'], opacity=1.0) ba.textwidget( edit=display['price_widget_left'], text=original_price_str ) ba.textwidget( edit=display['price_widget_right'], text=new_price_str ) # Add ticket button only if this is ticket-purchasable. if isinstance(offer.get('price'), int): self._get_tickets_button = ba.buttonwidget( parent=self._root_widget, position=(self._width - 125, self._height - 68), size=(90, 55), scale=1.0, button_type='square', color=(0.7, 0.5, 0.85), textcolor=(0.2, 1, 0.2), autoselect=True, label=ba.Lstr(resource='getTicketsWindow.titleText'), on_activate_call=self._on_get_more_tickets_press, ) self._ticket_text_update_timer = ba.Timer( 1.0, ba.WeakCall(self._update_tickets_text), timetype=ba.TimeType.REAL, repeat=True, ) self._update_tickets_text() self._update_timer = ba.Timer( 1.0, ba.WeakCall(self._update), timetype=ba.TimeType.REAL, repeat=True, ) self._cancel_button = ba.buttonwidget( parent=self._root_widget, position=(50, 40) if self._is_bundle_sale else (self._width * 0.5 - 75, 40), size=(150, 60), scale=1.0, on_activate_call=self._cancel, autoselect=True, label=ba.Lstr(resource='noThanksText'), ) self._cancel_countdown_text = ba.textwidget( parent=self._root_widget, text='', position=(50 + 150 + 20, 40 + 27) if self._is_bundle_sale else (self._width * 0.5 - 75 + 150 + 20, 40 + 27), scale=1.1, size=(0, 0), h_align='left', v_align='center', shadow=1.0, flatness=1.0, color=(0.6, 0.5, 0.5), ) self._update_cancel_button_graphics() if self._is_bundle_sale: self._purchase_button = ba.buttonwidget( parent=self._root_widget, position=(self._width - 200, 40), size=(150, 60), scale=1.0, on_activate_call=self._purchase, autoselect=True, label=ba.Lstr(resource='store.purchaseText'), ) ba.containerwidget( edit=self._root_widget, cancel_button=self._cancel_button, start_button=self._purchase_button if self._is_bundle_sale else None, selected_child=self._purchase_button if self._is_bundle_sale else display['button'], ) def _stop_flashing(self) -> None: self._flashing_timer = None ba.textwidget(edit=self._title_text, color=(0.3, 1, 0.3)) def _flash_cycle(self) -> None: if not self._root_widget: return self._flash_on = not self._flash_on ba.textwidget( edit=self._title_text, color=(0.3, 1, 0.3) if self._flash_on else (1, 0.5, 0), ) def _update_cancel_button_graphics(self) -> None: ba.buttonwidget( edit=self._cancel_button, color=(0.5, 0.5, 0.5) if self._cancel_delay > 0 else (0.7, 0.4, 0.34), textcolor=(0.5, 0.5, 0.5) if self._cancel_delay > 0 else (0.9, 0.9, 1.0), ) ba.textwidget( edit=self._cancel_countdown_text, text=str(self._cancel_delay) if self._cancel_delay > 0 else '', ) def _update(self) -> None: # If we've got seconds left on our countdown, update it. if self._cancel_delay > 0: self._cancel_delay = max(0, self._cancel_delay - 1) self._update_cancel_button_graphics() can_die = False # We go away if we see that our target item is owned. if self._offer_item == 'pro': if ba.app.accounts_v1.have_pro(): can_die = True else: if ba.internal.get_purchased(self._offer_item): can_die = True if can_die: self._transition_out('out_left') def _transition_out(self, transition: str = 'out_left') -> None: # Also clear any pending-special-offer we've stored at this point. cfg = ba.app.config if 'pendingSpecialOffer' in cfg: del cfg['pendingSpecialOffer'] cfg.commit() ba.containerwidget(edit=self._root_widget, transition=transition) def _update_tickets_text(self) -> None: from ba import SpecialChar if not self._root_widget: return sval: str | ba.Lstr if ba.internal.get_v1_account_state() == 'signed_in': sval = ba.charstr(SpecialChar.TICKET) + str( ba.internal.get_v1_account_ticket_count() ) else: sval = ba.Lstr(resource='getTicketsWindow.titleText') ba.buttonwidget(edit=self._get_tickets_button, label=sval) def _on_get_more_tickets_press(self) -> None: from bastd.ui import account from bastd.ui import getcurrency if ba.internal.get_v1_account_state() != 'signed_in': account.show_sign_in_prompt() return getcurrency.GetCurrencyWindow(modal=True).get_root_widget() def _purchase(self) -> None: from ba.internal import get_store_item_name_translated from bastd.ui import getcurrency from bastd.ui import confirm if self._offer['item'] == 'pro': ba.internal.purchase('pro_sale') elif self._offer['item'] == 'pro_fullprice': ba.internal.purchase('pro') elif self._is_bundle_sale: # With bundle sales, the price is the name of the IAP. ba.internal.purchase(self._offer['price']) else: ticket_count: int | None try: ticket_count = ba.internal.get_v1_account_ticket_count() except Exception: ticket_count = None if ticket_count is not None and ticket_count < self._offer['price']: getcurrency.show_get_tickets_prompt() ba.playsound(ba.getsound('error')) return def do_it() -> None: ba.internal.in_game_purchase( 'offer:' + str(self._offer['id']), self._offer['price'] ) ba.playsound(ba.getsound('swish')) confirm.ConfirmWindow( ba.Lstr( resource='store.purchaseConfirmText', subs=[ ( '${ITEM}', get_store_item_name_translated(self._offer['item']), ) ], ), width=400, height=120, action=do_it, ok_text=ba.Lstr( resource='store.purchaseText', fallback_resource='okText' ), ) def _cancel(self) -> None: if self._cancel_delay > 0: ba.playsound(ba.getsound('error')) return self._transition_out('out_right') def show_offer() -> bool: """(internal)""" try: from bastd.ui import feedback app = ba.app # Space things out a bit so we don't hit the poor user with an ad and # then an in-game offer. has_been_long_enough_since_ad = True if app.ads.last_ad_completion_time is not None and ( ba.time(ba.TimeType.REAL) - app.ads.last_ad_completion_time < 30.0 ): has_been_long_enough_since_ad = False if app.special_offer is not None and has_been_long_enough_since_ad: # Special case: for pro offers, store this in our prefs so we # can re-show it if the user kills us (set phasers to 'NAG'!!!). if app.special_offer.get('item') == 'pro_fullprice': cfg = app.config cfg['pendingSpecialOffer'] = { 'a': ba.internal.get_public_login_id(), 'o': app.special_offer, } cfg.commit() with ba.Context('ui'): if app.special_offer['item'] == 'rating': feedback.ask_for_rating() else: SpecialOfferWindow(app.special_offer) app.special_offer = None return True except Exception: ba.print_exception('Error showing offer.') return False