bastd.ui.gather.privatetab

Defines the Private tab in the gather UI.

  1# Released under the MIT License. See LICENSE for details.
  2#
  3"""Defines the Private tab in the gather UI."""
  4
  5from __future__ import annotations
  6
  7import os
  8import copy
  9import time
 10from enum import Enum
 11from dataclasses import dataclass
 12from typing import TYPE_CHECKING, cast
 13
 14import ba
 15import _ba
 16from efro.dataclassio import dataclass_from_dict, dataclass_to_dict
 17from bacommon.net import (PrivateHostingState, PrivateHostingConfig,
 18                          PrivatePartyConnectResult)
 19from bastd.ui.gather import GatherTab
 20from bastd.ui import getcurrency
 21
 22if TYPE_CHECKING:
 23    from typing import Any
 24    from bastd.ui.gather import GatherWindow
 25
 26# Print a bit of info about queries, etc.
 27DEBUG_SERVER_COMMUNICATION = os.environ.get('BA_DEBUG_PPTABCOM') == '1'
 28
 29
 30class SubTabType(Enum):
 31    """Available sub-tabs."""
 32    JOIN = 'join'
 33    HOST = 'host'
 34
 35
 36@dataclass
 37class State:
 38    """Our core state that persists while the app is running."""
 39    sub_tab: SubTabType = SubTabType.JOIN
 40
 41
 42class PrivateGatherTab(GatherTab):
 43    """The private tab in the gather UI"""
 44
 45    def __init__(self, window: GatherWindow) -> None:
 46        super().__init__(window)
 47        self._container: ba.Widget | None = None
 48        self._state: State = State()
 49        self._hostingstate = PrivateHostingState()
 50        self._join_sub_tab_text: ba.Widget | None = None
 51        self._host_sub_tab_text: ba.Widget | None = None
 52        self._update_timer: ba.Timer | None = None
 53        self._join_party_code_text: ba.Widget | None = None
 54        self._c_width: float = 0.0
 55        self._c_height: float = 0.0
 56        self._last_hosting_state_query_time: float | None = None
 57        self._waiting_for_initial_state = True
 58        self._waiting_for_start_stop_response = True
 59        self._host_playlist_button: ba.Widget | None = None
 60        self._host_copy_button: ba.Widget | None = None
 61        self._host_connect_button: ba.Widget | None = None
 62        self._host_start_stop_button: ba.Widget | None = None
 63        self._get_tickets_button: ba.Widget | None = None
 64        self._ticket_count_text: ba.Widget | None = None
 65        self._showing_not_signed_in_screen = False
 66        self._create_time = time.time()
 67        self._last_action_send_time: float | None = None
 68        self._connect_press_time: float | None = None
 69        try:
 70            self._hostingconfig = self._build_hosting_config()
 71        except Exception:
 72            ba.print_exception('Error building hosting config')
 73            self._hostingconfig = PrivateHostingConfig()
 74
 75    def on_activate(
 76        self,
 77        parent_widget: ba.Widget,
 78        tab_button: ba.Widget,
 79        region_width: float,
 80        region_height: float,
 81        region_left: float,
 82        region_bottom: float,
 83    ) -> ba.Widget:
 84        self._c_width = region_width
 85        self._c_height = region_height - 20
 86        self._container = ba.containerwidget(
 87            parent=parent_widget,
 88            position=(region_left,
 89                      region_bottom + (region_height - self._c_height) * 0.5),
 90            size=(self._c_width, self._c_height),
 91            background=False,
 92            selection_loops_to_parent=True)
 93        v = self._c_height - 30.0
 94        self._join_sub_tab_text = ba.textwidget(
 95            parent=self._container,
 96            position=(self._c_width * 0.5 - 245, v - 13),
 97            color=(0.6, 1.0, 0.6),
 98            scale=1.3,
 99            size=(200, 30),
100            maxwidth=250,
101            h_align='left',
102            v_align='center',
103            click_activate=True,
104            selectable=True,
105            autoselect=True,
106            on_activate_call=lambda: self._set_sub_tab(
107                SubTabType.JOIN,
108                playsound=True,
109            ),
110            text=ba.Lstr(resource='gatherWindow.privatePartyJoinText'))
111        self._host_sub_tab_text = ba.textwidget(
112            parent=self._container,
113            position=(self._c_width * 0.5 + 45, v - 13),
114            color=(0.6, 1.0, 0.6),
115            scale=1.3,
116            size=(200, 30),
117            maxwidth=250,
118            h_align='left',
119            v_align='center',
120            click_activate=True,
121            selectable=True,
122            autoselect=True,
123            on_activate_call=lambda: self._set_sub_tab(
124                SubTabType.HOST,
125                playsound=True,
126            ),
127            text=ba.Lstr(resource='gatherWindow.privatePartyHostText'))
128        ba.widget(edit=self._join_sub_tab_text, up_widget=tab_button)
129        ba.widget(edit=self._host_sub_tab_text,
130                  left_widget=self._join_sub_tab_text,
131                  up_widget=tab_button)
132        ba.widget(edit=self._join_sub_tab_text,
133                  right_widget=self._host_sub_tab_text)
134
135        self._update_timer = ba.Timer(1.0,
136                                      ba.WeakCall(self._update),
137                                      repeat=True,
138                                      timetype=ba.TimeType.REAL)
139
140        # Prevent taking any action until we've updated our state.
141        self._waiting_for_initial_state = True
142
143        # This will get a state query sent out immediately.
144        self._last_action_send_time = None  # Ensure we don't ignore response.
145        self._last_hosting_state_query_time = None
146        self._update()
147
148        self._set_sub_tab(self._state.sub_tab)
149
150        return self._container
151
152    def _build_hosting_config(self) -> PrivateHostingConfig:
153        # pylint: disable=too-many-branches
154        from bastd.ui.playlist import PlaylistTypeVars
155        from ba.internal import filter_playlist
156        hcfg = PrivateHostingConfig()
157        cfg = ba.app.config
158        sessiontypestr = cfg.get('Private Party Host Session Type', 'ffa')
159        if not isinstance(sessiontypestr, str):
160            raise RuntimeError(f'Invalid sessiontype {sessiontypestr}')
161        hcfg.session_type = sessiontypestr
162
163        sessiontype: type[ba.Session]
164        if hcfg.session_type == 'ffa':
165            sessiontype = ba.FreeForAllSession
166        elif hcfg.session_type == 'teams':
167            sessiontype = ba.DualTeamSession
168        else:
169            raise RuntimeError(f'Invalid sessiontype: {hcfg.session_type}')
170        pvars = PlaylistTypeVars(sessiontype)
171
172        playlist_name = ba.app.config.get(
173            f'{pvars.config_name} Playlist Selection')
174        if not isinstance(playlist_name, str):
175            playlist_name = '__default__'
176        hcfg.playlist_name = (pvars.default_list_name.evaluate()
177                              if playlist_name == '__default__' else
178                              playlist_name)
179
180        playlist: list[dict[str, Any]] | None = None
181        if playlist_name != '__default__':
182            playlist = (cfg.get(f'{pvars.config_name} Playlists',
183                                {}).get(playlist_name))
184        if playlist is None:
185            playlist = pvars.get_default_list_call()
186
187        hcfg.playlist = filter_playlist(playlist, sessiontype)
188
189        randomize = cfg.get(f'{pvars.config_name} Playlist Randomize')
190        if not isinstance(randomize, bool):
191            randomize = False
192        hcfg.randomize = randomize
193
194        tutorial = cfg.get('Show Tutorial')
195        if not isinstance(tutorial, bool):
196            tutorial = True
197        hcfg.tutorial = tutorial
198
199        if hcfg.session_type == 'teams':
200            ctn: list[str] | None = cfg.get('Custom Team Names')
201            if ctn is not None:
202                if (isinstance(ctn, (list, tuple)) and len(ctn) == 2
203                        and all(isinstance(x, str) for x in ctn)):
204                    hcfg.custom_team_names = (ctn[0], ctn[1])
205                else:
206                    print(f'Found invalid custom-team-names data: {ctn}')
207
208            ctc: list[list[float]] | None = cfg.get('Custom Team Colors')
209            if ctc is not None:
210                if (isinstance(ctc, (list, tuple)) and len(ctc) == 2
211                        and all(isinstance(x, (list, tuple)) for x in ctc)
212                        and all(len(x) == 3 for x in ctc)):
213                    hcfg.custom_team_colors = ((ctc[0][0], ctc[0][1],
214                                                ctc[0][2]),
215                                               (ctc[1][0], ctc[1][1],
216                                                ctc[1][2]))
217                else:
218                    print(f'Found invalid custom-team-colors data: {ctc}')
219
220        return hcfg
221
222    def on_deactivate(self) -> None:
223        self._update_timer = None
224
225    def _update_currency_ui(self) -> None:
226        # Keep currency count up to date if applicable.
227        try:
228            t_str = str(_ba.get_v1_account_ticket_count())
229        except Exception:
230            t_str = '?'
231        if self._get_tickets_button:
232            ba.buttonwidget(edit=self._get_tickets_button,
233                            label=ba.charstr(ba.SpecialChar.TICKET) + t_str)
234        if self._ticket_count_text:
235            ba.textwidget(edit=self._ticket_count_text,
236                          text=ba.charstr(ba.SpecialChar.TICKET) + t_str)
237
238    def _update(self) -> None:
239        """Periodic updating."""
240
241        now = ba.time(ba.TimeType.REAL)
242
243        self._update_currency_ui()
244
245        if self._state.sub_tab is SubTabType.HOST:
246
247            # If we're not signed in, just refresh to show that.
248            if (_ba.get_v1_account_state() != 'signed_in'
249                    and self._showing_not_signed_in_screen):
250                self._refresh_sub_tab()
251            else:
252
253                # Query an updated state periodically.
254                if (self._last_hosting_state_query_time is None
255                        or now - self._last_hosting_state_query_time > 15.0):
256                    self._debug_server_comm('querying private party state')
257                    if _ba.get_v1_account_state() == 'signed_in':
258                        _ba.add_transaction(
259                            {
260                                'type': 'PRIVATE_PARTY_QUERY',
261                                'expire_time': time.time() + 20,
262                            },
263                            callback=ba.WeakCall(
264                                self._hosting_state_idle_response),
265                        )
266                        _ba.run_transactions()
267                    else:
268                        self._hosting_state_idle_response(None)
269                    self._last_hosting_state_query_time = now
270
271    def _hosting_state_idle_response(self,
272                                     result: dict[str, Any] | None) -> None:
273
274        # This simply passes through to our standard response handler.
275        # The one exception is if we've recently sent an action to the
276        # server (start/stop hosting/etc.) In that case we want to ignore
277        # idle background updates and wait for the response to our action.
278        # (this keeps the button showing 'one moment...' until the change
279        # takes effect, etc.)
280        if (self._last_action_send_time is not None
281                and time.time() - self._last_action_send_time < 5.0):
282            self._debug_server_comm('ignoring private party state response'
283                                    ' due to recent action')
284            return
285        self._hosting_state_response(result)
286
287    def _hosting_state_response(self, result: dict[str, Any] | None) -> None:
288
289        # Its possible for this to come back to us after our UI is dead;
290        # ignore in that case.
291        if not self._container:
292            return
293
294        state: PrivateHostingState | None = None
295        if result is not None:
296            self._debug_server_comm('got private party state response')
297            try:
298                state = dataclass_from_dict(PrivateHostingState,
299                                            result,
300                                            discard_unknown_attrs=True)
301            except Exception:
302                ba.print_exception('Got invalid PrivateHostingState data')
303        else:
304            self._debug_server_comm('private party state response errored')
305
306        # Hmm I guess let's just ignore failed responses?...
307        # Or should we show some sort of error state to the user?...
308        if result is None or state is None:
309            return
310
311        self._waiting_for_initial_state = False
312        self._waiting_for_start_stop_response = False
313        self._hostingstate = state
314        self._refresh_sub_tab()
315
316    def _set_sub_tab(self, value: SubTabType, playsound: bool = False) -> None:
317        assert self._container
318        if playsound:
319            ba.playsound(ba.getsound('click01'))
320
321        # If switching from join to host, do a fresh state query.
322        if self._state.sub_tab is SubTabType.JOIN and value is SubTabType.HOST:
323            # Prevent taking any action until we've gotten a fresh state.
324            self._waiting_for_initial_state = True
325
326            # This will get a state query sent out immediately.
327            self._last_hosting_state_query_time = None
328            self._last_action_send_time = None  # So we don't ignore response.
329            self._update()
330
331        self._state.sub_tab = value
332        active_color = (0.6, 1.0, 0.6)
333        inactive_color = (0.5, 0.4, 0.5)
334        ba.textwidget(
335            edit=self._join_sub_tab_text,
336            color=active_color if value is SubTabType.JOIN else inactive_color)
337        ba.textwidget(
338            edit=self._host_sub_tab_text,
339            color=active_color if value is SubTabType.HOST else inactive_color)
340
341        self._refresh_sub_tab()
342
343        # Kick off an update to get any needed messages sent/etc.
344        ba.pushcall(self._update)
345
346    def _selwidgets(self) -> list[ba.Widget | None]:
347        """An indexed list of widgets we can use for saving/restoring sel."""
348        return [
349            self._host_playlist_button, self._host_copy_button,
350            self._host_connect_button, self._host_start_stop_button,
351            self._get_tickets_button
352        ]
353
354    def _refresh_sub_tab(self) -> None:
355        assert self._container
356
357        # Store an index for our current selection so we can
358        # reselect the equivalent recreated widget if possible.
359        selindex: int | None = None
360        selchild = self._container.get_selected_child()
361        if selchild is not None:
362            try:
363                selindex = self._selwidgets().index(selchild)
364            except ValueError:
365                pass
366
367        # Clear anything existing in the old sub-tab.
368        for widget in self._container.get_children():
369            if widget and widget not in {
370                    self._host_sub_tab_text,
371                    self._join_sub_tab_text,
372            }:
373                widget.delete()
374
375        if self._state.sub_tab is SubTabType.JOIN:
376            self._build_join_tab()
377        elif self._state.sub_tab is SubTabType.HOST:
378            self._build_host_tab()
379        else:
380            raise RuntimeError('Invalid state.')
381
382        # Select the new equivalent widget if there is one.
383        if selindex is not None:
384            selwidget = self._selwidgets()[selindex]
385            if selwidget:
386                ba.containerwidget(edit=self._container,
387                                   selected_child=selwidget)
388
389    def _build_join_tab(self) -> None:
390
391        ba.textwidget(parent=self._container,
392                      position=(self._c_width * 0.5, self._c_height - 140),
393                      color=(0.5, 0.46, 0.5),
394                      scale=1.5,
395                      size=(0, 0),
396                      maxwidth=250,
397                      h_align='center',
398                      v_align='center',
399                      text=ba.Lstr(resource='gatherWindow.partyCodeText'))
400
401        self._join_party_code_text = ba.textwidget(
402            parent=self._container,
403            position=(self._c_width * 0.5 - 150, self._c_height - 250),
404            flatness=1.0,
405            scale=1.5,
406            size=(300, 50),
407            editable=True,
408            description=ba.Lstr(resource='gatherWindow.partyCodeText'),
409            autoselect=True,
410            maxwidth=250,
411            h_align='left',
412            v_align='center',
413            text='')
414        btn = ba.buttonwidget(parent=self._container,
415                              size=(300, 70),
416                              label=ba.Lstr(resource='gatherWindow.'
417                                            'manualConnectText'),
418                              position=(self._c_width * 0.5 - 150,
419                                        self._c_height - 350),
420                              on_activate_call=self._join_connect_press,
421                              autoselect=True)
422        ba.textwidget(edit=self._join_party_code_text,
423                      on_return_press_call=btn.activate)
424
425    def _on_get_tickets_press(self) -> None:
426
427        if self._waiting_for_start_stop_response:
428            return
429
430        # Bring up get-tickets window and then kill ourself (we're on the
431        # overlay layer so we'd show up above it).
432        getcurrency.GetCurrencyWindow(modal=True,
433                                      origin_widget=self._get_tickets_button)
434
435    def _build_host_tab(self) -> None:
436        # pylint: disable=too-many-branches
437        # pylint: disable=too-many-statements
438
439        if _ba.get_v1_account_state() != 'signed_in':
440            ba.textwidget(parent=self._container,
441                          size=(0, 0),
442                          h_align='center',
443                          v_align='center',
444                          maxwidth=200,
445                          scale=0.8,
446                          color=(0.6, 0.56, 0.6),
447                          position=(self._c_width * 0.5, self._c_height * 0.5),
448                          text=ba.Lstr(resource='notSignedInErrorText'))
449            self._showing_not_signed_in_screen = True
450            return
451        self._showing_not_signed_in_screen = False
452
453        # At first we don't want to show anything until we've gotten a state.
454        # Update: In this situation we now simply show our existing state
455        # but give the start/stop button a loading message and disallow its
456        # use. This keeps things a lot less jumpy looking and allows selecting
457        # playlists/etc without having to wait for the server each time
458        # back to the ui.
459        if self._waiting_for_initial_state and bool(False):
460            ba.textwidget(
461                parent=self._container,
462                size=(0, 0),
463                h_align='center',
464                v_align='center',
465                maxwidth=200,
466                scale=0.8,
467                color=(0.6, 0.56, 0.6),
468                position=(self._c_width * 0.5, self._c_height * 0.5),
469                text=ba.Lstr(
470                    value='${A}...',
471                    subs=[('${A}', ba.Lstr(resource='store.loadingText'))],
472                ),
473            )
474            return
475
476        # If we're not currently hosting and hosting requires tickets,
477        # Show our count (possibly with a link to purchase more).
478        if (not self._waiting_for_initial_state
479                and self._hostingstate.party_code is None
480                and self._hostingstate.tickets_to_host_now != 0):
481            if not ba.app.ui.use_toolbars:
482                if ba.app.allow_ticket_purchases:
483                    self._get_tickets_button = ba.buttonwidget(
484                        parent=self._container,
485                        position=(self._c_width - 210 + 125,
486                                  self._c_height - 44),
487                        autoselect=True,
488                        scale=0.6,
489                        size=(120, 60),
490                        textcolor=(0.2, 1, 0.2),
491                        label=ba.charstr(ba.SpecialChar.TICKET),
492                        color=(0.65, 0.5, 0.8),
493                        on_activate_call=self._on_get_tickets_press)
494                else:
495                    self._ticket_count_text = ba.textwidget(
496                        parent=self._container,
497                        scale=0.6,
498                        position=(self._c_width - 210 + 125,
499                                  self._c_height - 44),
500                        color=(0.2, 1, 0.2),
501                        h_align='center',
502                        v_align='center')
503                # Set initial ticket count.
504                self._update_currency_ui()
505
506        v = self._c_height - 90
507        if self._hostingstate.party_code is None:
508            ba.textwidget(
509                parent=self._container,
510                size=(0, 0),
511                h_align='center',
512                v_align='center',
513                maxwidth=self._c_width * 0.9,
514                scale=0.7,
515                flatness=1.0,
516                color=(0.5, 0.46, 0.5),
517                position=(self._c_width * 0.5, v),
518                text=ba.Lstr(
519                    resource='gatherWindow.privatePartyCloudDescriptionText'))
520
521        v -= 100
522        if self._hostingstate.party_code is None:
523            # We've got no current party running; show options to set one up.
524            ba.textwidget(parent=self._container,
525                          size=(0, 0),
526                          h_align='right',
527                          v_align='center',
528                          maxwidth=200,
529                          scale=0.8,
530                          color=(0.6, 0.56, 0.6),
531                          position=(self._c_width * 0.5 - 210, v),
532                          text=ba.Lstr(resource='playlistText'))
533            self._host_playlist_button = ba.buttonwidget(
534                parent=self._container,
535                size=(400, 70),
536                color=(0.6, 0.5, 0.6),
537                textcolor=(0.8, 0.75, 0.8),
538                label=self._hostingconfig.playlist_name,
539                on_activate_call=self._playlist_press,
540                position=(self._c_width * 0.5 - 200, v - 35),
541                up_widget=self._host_sub_tab_text,
542                autoselect=True)
543
544            # If it appears we're coming back from playlist selection,
545            # re-select our playlist button.
546            if ba.app.ui.selecting_private_party_playlist:
547                ba.containerwidget(edit=self._container,
548                                   selected_child=self._host_playlist_button)
549                ba.app.ui.selecting_private_party_playlist = False
550        else:
551            # We've got a current party; show its info.
552            ba.textwidget(
553                parent=self._container,
554                size=(0, 0),
555                h_align='center',
556                v_align='center',
557                maxwidth=600,
558                scale=0.9,
559                color=(0.7, 0.64, 0.7),
560                position=(self._c_width * 0.5, v + 90),
561                text=ba.Lstr(resource='gatherWindow.partyServerRunningText'))
562            ba.textwidget(parent=self._container,
563                          size=(0, 0),
564                          h_align='center',
565                          v_align='center',
566                          maxwidth=600,
567                          scale=0.7,
568                          color=(0.7, 0.64, 0.7),
569                          position=(self._c_width * 0.5, v + 50),
570                          text=ba.Lstr(resource='gatherWindow.partyCodeText'))
571            ba.textwidget(parent=self._container,
572                          size=(0, 0),
573                          h_align='center',
574                          v_align='center',
575                          scale=2.0,
576                          color=(0.0, 1.0, 0.0),
577                          position=(self._c_width * 0.5, v + 10),
578                          text=self._hostingstate.party_code)
579
580            # Also action buttons to copy it and connect to it.
581            if ba.clipboard_is_supported():
582                cbtnoffs = 10
583                self._host_copy_button = ba.buttonwidget(
584                    parent=self._container,
585                    size=(140, 40),
586                    color=(0.6, 0.5, 0.6),
587                    textcolor=(0.8, 0.75, 0.8),
588                    label=ba.Lstr(resource='gatherWindow.copyCodeText'),
589                    on_activate_call=self._host_copy_press,
590                    position=(self._c_width * 0.5 - 150, v - 70),
591                    autoselect=True)
592            else:
593                cbtnoffs = -70
594            self._host_connect_button = ba.buttonwidget(
595                parent=self._container,
596                size=(140, 40),
597                color=(0.6, 0.5, 0.6),
598                textcolor=(0.8, 0.75, 0.8),
599                label=ba.Lstr(resource='gatherWindow.manualConnectText'),
600                on_activate_call=self._host_connect_press,
601                position=(self._c_width * 0.5 + cbtnoffs, v - 70),
602                autoselect=True)
603
604        v -= 120
605
606        # Line above the main action button:
607
608        # If we don't want to show anything until we get a state:
609        if self._waiting_for_initial_state:
610            pass
611        elif self._hostingstate.unavailable_error is not None:
612            # If hosting is unavailable, show the associated reason.
613            ba.textwidget(
614                parent=self._container,
615                size=(0, 0),
616                h_align='center',
617                v_align='center',
618                maxwidth=self._c_width * 0.9,
619                scale=0.7,
620                flatness=1.0,
621                color=(1.0, 0.0, 0.0),
622                position=(self._c_width * 0.5, v),
623                text=ba.Lstr(translate=('serverResponses',
624                                        self._hostingstate.unavailable_error)))
625        elif self._hostingstate.free_host_minutes_remaining is not None:
626            # If we've been pre-approved to start/stop for free, show that.
627            ba.textwidget(
628                parent=self._container,
629                size=(0, 0),
630                h_align='center',
631                v_align='center',
632                maxwidth=self._c_width * 0.9,
633                scale=0.7,
634                flatness=1.0,
635                color=((0.7, 0.64, 0.7) if self._hostingstate.party_code else
636                       (0.0, 1.0, 0.0)),
637                position=(self._c_width * 0.5, v),
638                text=ba.Lstr(
639                    resource='gatherWindow.startStopHostingMinutesText',
640                    subs=[(
641                        '${MINUTES}',
642                        f'{self._hostingstate.free_host_minutes_remaining:.0f}'
643                    )]))
644        else:
645            # Otherwise tell whether the free cloud server is available
646            # or will be at some point.
647            if self._hostingstate.party_code is None:
648                if self._hostingstate.tickets_to_host_now == 0:
649                    ba.textwidget(
650                        parent=self._container,
651                        size=(0, 0),
652                        h_align='center',
653                        v_align='center',
654                        maxwidth=self._c_width * 0.9,
655                        scale=0.7,
656                        flatness=1.0,
657                        color=(0.0, 1.0, 0.0),
658                        position=(self._c_width * 0.5, v),
659                        text=ba.Lstr(
660                            resource=
661                            'gatherWindow.freeCloudServerAvailableNowText'))
662                else:
663                    if self._hostingstate.minutes_until_free_host is None:
664                        ba.textwidget(
665                            parent=self._container,
666                            size=(0, 0),
667                            h_align='center',
668                            v_align='center',
669                            maxwidth=self._c_width * 0.9,
670                            scale=0.7,
671                            flatness=1.0,
672                            color=(1.0, 0.6, 0.0),
673                            position=(self._c_width * 0.5, v),
674                            text=ba.Lstr(
675                                resource=
676                                'gatherWindow.freeCloudServerNotAvailableText')
677                        )
678                    else:
679                        availmins = self._hostingstate.minutes_until_free_host
680                        ba.textwidget(
681                            parent=self._container,
682                            size=(0, 0),
683                            h_align='center',
684                            v_align='center',
685                            maxwidth=self._c_width * 0.9,
686                            scale=0.7,
687                            flatness=1.0,
688                            color=(1.0, 0.6, 0.0),
689                            position=(self._c_width * 0.5, v),
690                            text=ba.Lstr(resource='gatherWindow.'
691                                         'freeCloudServerAvailableMinutesText',
692                                         subs=[('${MINUTES}',
693                                                f'{availmins:.0f}')]))
694
695        v -= 100
696
697        if (self._waiting_for_start_stop_response
698                or self._waiting_for_initial_state):
699            btnlabel = ba.Lstr(resource='oneMomentText')
700        else:
701            if self._hostingstate.unavailable_error is not None:
702                btnlabel = ba.Lstr(
703                    resource='gatherWindow.hostingUnavailableText')
704            elif self._hostingstate.party_code is None:
705                ticon = _ba.charstr(ba.SpecialChar.TICKET)
706                nowtickets = self._hostingstate.tickets_to_host_now
707                if nowtickets > 0:
708                    btnlabel = ba.Lstr(
709                        resource='gatherWindow.startHostingPaidText',
710                        subs=[('${COST}', f'{ticon}{nowtickets}')])
711                else:
712                    btnlabel = ba.Lstr(
713                        resource='gatherWindow.startHostingText')
714            else:
715                btnlabel = ba.Lstr(resource='gatherWindow.stopHostingText')
716
717        disabled = (self._hostingstate.unavailable_error is not None
718                    or self._waiting_for_initial_state)
719        waiting = self._waiting_for_start_stop_response
720        self._host_start_stop_button = ba.buttonwidget(
721            parent=self._container,
722            size=(400, 80),
723            color=((0.6, 0.6, 0.6) if disabled else
724                   (0.5, 1.0, 0.5) if waiting else None),
725            enable_sound=False,
726            label=btnlabel,
727            textcolor=((0.7, 0.7, 0.7) if disabled else None),
728            position=(self._c_width * 0.5 - 200, v),
729            on_activate_call=self._start_stop_button_press,
730            autoselect=True)
731
732    def _playlist_press(self) -> None:
733        assert self._host_playlist_button is not None
734        self.window.playlist_select(origin_widget=self._host_playlist_button)
735
736    def _host_copy_press(self) -> None:
737        assert self._hostingstate.party_code is not None
738        ba.clipboard_set_text(self._hostingstate.party_code)
739        ba.screenmessage(ba.Lstr(resource='gatherWindow.copyCodeConfirmText'))
740
741    def _host_connect_press(self) -> None:
742        assert self._hostingstate.party_code is not None
743        self._connect_to_party_code(self._hostingstate.party_code)
744
745    def _debug_server_comm(self, msg: str) -> None:
746        if DEBUG_SERVER_COMMUNICATION:
747            print(f'PPTABCOM: {msg} at time '
748                  f'{time.time()-self._create_time:.2f}')
749
750    def _connect_to_party_code(self, code: str) -> None:
751
752        # Ignore attempted followup sends for a few seconds.
753        # (this will reset if we get a response)
754        now = time.time()
755        if (self._connect_press_time is not None
756                and now - self._connect_press_time < 5.0):
757            self._debug_server_comm(
758                'not sending private party connect (too soon)')
759            return
760        self._connect_press_time = now
761
762        self._debug_server_comm('sending private party connect')
763        _ba.add_transaction(
764            {
765                'type': 'PRIVATE_PARTY_CONNECT',
766                'expire_time': time.time() + 20,
767                'code': code
768            },
769            callback=ba.WeakCall(self._connect_response),
770        )
771        _ba.run_transactions()
772
773    def _start_stop_button_press(self) -> None:
774        if (self._waiting_for_start_stop_response
775                or self._waiting_for_initial_state):
776            return
777
778        if _ba.get_v1_account_state() != 'signed_in':
779            ba.screenmessage(ba.Lstr(resource='notSignedInErrorText'))
780            ba.playsound(ba.getsound('error'))
781            self._refresh_sub_tab()
782            return
783
784        if self._hostingstate.unavailable_error is not None:
785            ba.playsound(ba.getsound('error'))
786            return
787
788        ba.playsound(ba.getsound('click01'))
789
790        # If we're not hosting, start.
791        if self._hostingstate.party_code is None:
792
793            # If there's a ticket cost, make sure we have enough tickets.
794            if self._hostingstate.tickets_to_host_now > 0:
795                ticket_count: int | None
796                try:
797                    ticket_count = _ba.get_v1_account_ticket_count()
798                except Exception:
799                    # FIXME: should add a ba.NotSignedInError we can use here.
800                    ticket_count = None
801                ticket_cost = self._hostingstate.tickets_to_host_now
802                if ticket_count is not None and ticket_count < ticket_cost:
803                    getcurrency.show_get_tickets_prompt()
804                    ba.playsound(ba.getsound('error'))
805                    return
806            self._last_action_send_time = time.time()
807            _ba.add_transaction(
808                {
809                    'type': 'PRIVATE_PARTY_START',
810                    'config': dataclass_to_dict(self._hostingconfig),
811                    'region_pings': ba.app.net.zone_pings,
812                    'expire_time': time.time() + 20,
813                },
814                callback=ba.WeakCall(self._hosting_state_response))
815            _ba.run_transactions()
816
817        else:
818            self._last_action_send_time = time.time()
819            _ba.add_transaction(
820                {
821                    'type': 'PRIVATE_PARTY_STOP',
822                    'expire_time': time.time() + 20,
823                },
824                callback=ba.WeakCall(self._hosting_state_response))
825            _ba.run_transactions()
826        ba.playsound(ba.getsound('click01'))
827
828        self._waiting_for_start_stop_response = True
829        self._refresh_sub_tab()
830
831    def _join_connect_press(self) -> None:
832
833        # Error immediately if its an empty code.
834        code: str | None = None
835        if self._join_party_code_text:
836            code = cast(str, ba.textwidget(query=self._join_party_code_text))
837        if not code:
838            ba.screenmessage(
839                ba.Lstr(resource='internal.invalidAddressErrorText'),
840                color=(1, 0, 0))
841            ba.playsound(ba.getsound('error'))
842            return
843
844        self._connect_to_party_code(code)
845
846    def _connect_response(self, result: dict[str, Any] | None) -> None:
847        try:
848            self._connect_press_time = None
849            if result is None:
850                raise RuntimeError()
851            cresult = dataclass_from_dict(PrivatePartyConnectResult,
852                                          result,
853                                          discard_unknown_attrs=True)
854            if cresult.error is not None:
855                self._debug_server_comm('got error connect response')
856                ba.screenmessage(
857                    ba.Lstr(translate=('serverResponses', cresult.error)),
858                    (1, 0, 0))
859                ba.playsound(ba.getsound('error'))
860                return
861            self._debug_server_comm('got valid connect response')
862            assert cresult.addr is not None and cresult.port is not None
863            _ba.connect_to_party(cresult.addr, port=cresult.port)
864        except Exception:
865            self._debug_server_comm('got connect response error')
866            ba.playsound(ba.getsound('error'))
867
868    def save_state(self) -> None:
869        ba.app.ui.window_states[type(self)] = copy.deepcopy(self._state)
870
871    def restore_state(self) -> None:
872        state = ba.app.ui.window_states.get(type(self))
873        if state is None:
874            state = State()
875        assert isinstance(state, State)
876        self._state = state
class SubTabType(enum.Enum):
31class SubTabType(Enum):
32    """Available sub-tabs."""
33    JOIN = 'join'
34    HOST = 'host'

Available sub-tabs.

JOIN = <SubTabType.JOIN: 'join'>
HOST = <SubTabType.HOST: 'host'>
Inherited Members
enum.Enum
name
value
@dataclass
class State:
37@dataclass
38class State:
39    """Our core state that persists while the app is running."""
40    sub_tab: SubTabType = SubTabType.JOIN

Our core state that persists while the app is running.

class PrivateGatherTab(bastd.ui.gather.GatherTab):
 43class PrivateGatherTab(GatherTab):
 44    """The private tab in the gather UI"""
 45
 46    def __init__(self, window: GatherWindow) -> None:
 47        super().__init__(window)
 48        self._container: ba.Widget | None = None
 49        self._state: State = State()
 50        self._hostingstate = PrivateHostingState()
 51        self._join_sub_tab_text: ba.Widget | None = None
 52        self._host_sub_tab_text: ba.Widget | None = None
 53        self._update_timer: ba.Timer | None = None
 54        self._join_party_code_text: ba.Widget | None = None
 55        self._c_width: float = 0.0
 56        self._c_height: float = 0.0
 57        self._last_hosting_state_query_time: float | None = None
 58        self._waiting_for_initial_state = True
 59        self._waiting_for_start_stop_response = True
 60        self._host_playlist_button: ba.Widget | None = None
 61        self._host_copy_button: ba.Widget | None = None
 62        self._host_connect_button: ba.Widget | None = None
 63        self._host_start_stop_button: ba.Widget | None = None
 64        self._get_tickets_button: ba.Widget | None = None
 65        self._ticket_count_text: ba.Widget | None = None
 66        self._showing_not_signed_in_screen = False
 67        self._create_time = time.time()
 68        self._last_action_send_time: float | None = None
 69        self._connect_press_time: float | None = None
 70        try:
 71            self._hostingconfig = self._build_hosting_config()
 72        except Exception:
 73            ba.print_exception('Error building hosting config')
 74            self._hostingconfig = PrivateHostingConfig()
 75
 76    def on_activate(
 77        self,
 78        parent_widget: ba.Widget,
 79        tab_button: ba.Widget,
 80        region_width: float,
 81        region_height: float,
 82        region_left: float,
 83        region_bottom: float,
 84    ) -> ba.Widget:
 85        self._c_width = region_width
 86        self._c_height = region_height - 20
 87        self._container = ba.containerwidget(
 88            parent=parent_widget,
 89            position=(region_left,
 90                      region_bottom + (region_height - self._c_height) * 0.5),
 91            size=(self._c_width, self._c_height),
 92            background=False,
 93            selection_loops_to_parent=True)
 94        v = self._c_height - 30.0
 95        self._join_sub_tab_text = ba.textwidget(
 96            parent=self._container,
 97            position=(self._c_width * 0.5 - 245, v - 13),
 98            color=(0.6, 1.0, 0.6),
 99            scale=1.3,
100            size=(200, 30),
101            maxwidth=250,
102            h_align='left',
103            v_align='center',
104            click_activate=True,
105            selectable=True,
106            autoselect=True,
107            on_activate_call=lambda: self._set_sub_tab(
108                SubTabType.JOIN,
109                playsound=True,
110            ),
111            text=ba.Lstr(resource='gatherWindow.privatePartyJoinText'))
112        self._host_sub_tab_text = ba.textwidget(
113            parent=self._container,
114            position=(self._c_width * 0.5 + 45, v - 13),
115            color=(0.6, 1.0, 0.6),
116            scale=1.3,
117            size=(200, 30),
118            maxwidth=250,
119            h_align='left',
120            v_align='center',
121            click_activate=True,
122            selectable=True,
123            autoselect=True,
124            on_activate_call=lambda: self._set_sub_tab(
125                SubTabType.HOST,
126                playsound=True,
127            ),
128            text=ba.Lstr(resource='gatherWindow.privatePartyHostText'))
129        ba.widget(edit=self._join_sub_tab_text, up_widget=tab_button)
130        ba.widget(edit=self._host_sub_tab_text,
131                  left_widget=self._join_sub_tab_text,
132                  up_widget=tab_button)
133        ba.widget(edit=self._join_sub_tab_text,
134                  right_widget=self._host_sub_tab_text)
135
136        self._update_timer = ba.Timer(1.0,
137                                      ba.WeakCall(self._update),
138                                      repeat=True,
139                                      timetype=ba.TimeType.REAL)
140
141        # Prevent taking any action until we've updated our state.
142        self._waiting_for_initial_state = True
143
144        # This will get a state query sent out immediately.
145        self._last_action_send_time = None  # Ensure we don't ignore response.
146        self._last_hosting_state_query_time = None
147        self._update()
148
149        self._set_sub_tab(self._state.sub_tab)
150
151        return self._container
152
153    def _build_hosting_config(self) -> PrivateHostingConfig:
154        # pylint: disable=too-many-branches
155        from bastd.ui.playlist import PlaylistTypeVars
156        from ba.internal import filter_playlist
157        hcfg = PrivateHostingConfig()
158        cfg = ba.app.config
159        sessiontypestr = cfg.get('Private Party Host Session Type', 'ffa')
160        if not isinstance(sessiontypestr, str):
161            raise RuntimeError(f'Invalid sessiontype {sessiontypestr}')
162        hcfg.session_type = sessiontypestr
163
164        sessiontype: type[ba.Session]
165        if hcfg.session_type == 'ffa':
166            sessiontype = ba.FreeForAllSession
167        elif hcfg.session_type == 'teams':
168            sessiontype = ba.DualTeamSession
169        else:
170            raise RuntimeError(f'Invalid sessiontype: {hcfg.session_type}')
171        pvars = PlaylistTypeVars(sessiontype)
172
173        playlist_name = ba.app.config.get(
174            f'{pvars.config_name} Playlist Selection')
175        if not isinstance(playlist_name, str):
176            playlist_name = '__default__'
177        hcfg.playlist_name = (pvars.default_list_name.evaluate()
178                              if playlist_name == '__default__' else
179                              playlist_name)
180
181        playlist: list[dict[str, Any]] | None = None
182        if playlist_name != '__default__':
183            playlist = (cfg.get(f'{pvars.config_name} Playlists',
184                                {}).get(playlist_name))
185        if playlist is None:
186            playlist = pvars.get_default_list_call()
187
188        hcfg.playlist = filter_playlist(playlist, sessiontype)
189
190        randomize = cfg.get(f'{pvars.config_name} Playlist Randomize')
191        if not isinstance(randomize, bool):
192            randomize = False
193        hcfg.randomize = randomize
194
195        tutorial = cfg.get('Show Tutorial')
196        if not isinstance(tutorial, bool):
197            tutorial = True
198        hcfg.tutorial = tutorial
199
200        if hcfg.session_type == 'teams':
201            ctn: list[str] | None = cfg.get('Custom Team Names')
202            if ctn is not None:
203                if (isinstance(ctn, (list, tuple)) and len(ctn) == 2
204                        and all(isinstance(x, str) for x in ctn)):
205                    hcfg.custom_team_names = (ctn[0], ctn[1])
206                else:
207                    print(f'Found invalid custom-team-names data: {ctn}')
208
209            ctc: list[list[float]] | None = cfg.get('Custom Team Colors')
210            if ctc is not None:
211                if (isinstance(ctc, (list, tuple)) and len(ctc) == 2
212                        and all(isinstance(x, (list, tuple)) for x in ctc)
213                        and all(len(x) == 3 for x in ctc)):
214                    hcfg.custom_team_colors = ((ctc[0][0], ctc[0][1],
215                                                ctc[0][2]),
216                                               (ctc[1][0], ctc[1][1],
217                                                ctc[1][2]))
218                else:
219                    print(f'Found invalid custom-team-colors data: {ctc}')
220
221        return hcfg
222
223    def on_deactivate(self) -> None:
224        self._update_timer = None
225
226    def _update_currency_ui(self) -> None:
227        # Keep currency count up to date if applicable.
228        try:
229            t_str = str(_ba.get_v1_account_ticket_count())
230        except Exception:
231            t_str = '?'
232        if self._get_tickets_button:
233            ba.buttonwidget(edit=self._get_tickets_button,
234                            label=ba.charstr(ba.SpecialChar.TICKET) + t_str)
235        if self._ticket_count_text:
236            ba.textwidget(edit=self._ticket_count_text,
237                          text=ba.charstr(ba.SpecialChar.TICKET) + t_str)
238
239    def _update(self) -> None:
240        """Periodic updating."""
241
242        now = ba.time(ba.TimeType.REAL)
243
244        self._update_currency_ui()
245
246        if self._state.sub_tab is SubTabType.HOST:
247
248            # If we're not signed in, just refresh to show that.
249            if (_ba.get_v1_account_state() != 'signed_in'
250                    and self._showing_not_signed_in_screen):
251                self._refresh_sub_tab()
252            else:
253
254                # Query an updated state periodically.
255                if (self._last_hosting_state_query_time is None
256                        or now - self._last_hosting_state_query_time > 15.0):
257                    self._debug_server_comm('querying private party state')
258                    if _ba.get_v1_account_state() == 'signed_in':
259                        _ba.add_transaction(
260                            {
261                                'type': 'PRIVATE_PARTY_QUERY',
262                                'expire_time': time.time() + 20,
263                            },
264                            callback=ba.WeakCall(
265                                self._hosting_state_idle_response),
266                        )
267                        _ba.run_transactions()
268                    else:
269                        self._hosting_state_idle_response(None)
270                    self._last_hosting_state_query_time = now
271
272    def _hosting_state_idle_response(self,
273                                     result: dict[str, Any] | None) -> None:
274
275        # This simply passes through to our standard response handler.
276        # The one exception is if we've recently sent an action to the
277        # server (start/stop hosting/etc.) In that case we want to ignore
278        # idle background updates and wait for the response to our action.
279        # (this keeps the button showing 'one moment...' until the change
280        # takes effect, etc.)
281        if (self._last_action_send_time is not None
282                and time.time() - self._last_action_send_time < 5.0):
283            self._debug_server_comm('ignoring private party state response'
284                                    ' due to recent action')
285            return
286        self._hosting_state_response(result)
287
288    def _hosting_state_response(self, result: dict[str, Any] | None) -> None:
289
290        # Its possible for this to come back to us after our UI is dead;
291        # ignore in that case.
292        if not self._container:
293            return
294
295        state: PrivateHostingState | None = None
296        if result is not None:
297            self._debug_server_comm('got private party state response')
298            try:
299                state = dataclass_from_dict(PrivateHostingState,
300                                            result,
301                                            discard_unknown_attrs=True)
302            except Exception:
303                ba.print_exception('Got invalid PrivateHostingState data')
304        else:
305            self._debug_server_comm('private party state response errored')
306
307        # Hmm I guess let's just ignore failed responses?...
308        # Or should we show some sort of error state to the user?...
309        if result is None or state is None:
310            return
311
312        self._waiting_for_initial_state = False
313        self._waiting_for_start_stop_response = False
314        self._hostingstate = state
315        self._refresh_sub_tab()
316
317    def _set_sub_tab(self, value: SubTabType, playsound: bool = False) -> None:
318        assert self._container
319        if playsound:
320            ba.playsound(ba.getsound('click01'))
321
322        # If switching from join to host, do a fresh state query.
323        if self._state.sub_tab is SubTabType.JOIN and value is SubTabType.HOST:
324            # Prevent taking any action until we've gotten a fresh state.
325            self._waiting_for_initial_state = True
326
327            # This will get a state query sent out immediately.
328            self._last_hosting_state_query_time = None
329            self._last_action_send_time = None  # So we don't ignore response.
330            self._update()
331
332        self._state.sub_tab = value
333        active_color = (0.6, 1.0, 0.6)
334        inactive_color = (0.5, 0.4, 0.5)
335        ba.textwidget(
336            edit=self._join_sub_tab_text,
337            color=active_color if value is SubTabType.JOIN else inactive_color)
338        ba.textwidget(
339            edit=self._host_sub_tab_text,
340            color=active_color if value is SubTabType.HOST else inactive_color)
341
342        self._refresh_sub_tab()
343
344        # Kick off an update to get any needed messages sent/etc.
345        ba.pushcall(self._update)
346
347    def _selwidgets(self) -> list[ba.Widget | None]:
348        """An indexed list of widgets we can use for saving/restoring sel."""
349        return [
350            self._host_playlist_button, self._host_copy_button,
351            self._host_connect_button, self._host_start_stop_button,
352            self._get_tickets_button
353        ]
354
355    def _refresh_sub_tab(self) -> None:
356        assert self._container
357
358        # Store an index for our current selection so we can
359        # reselect the equivalent recreated widget if possible.
360        selindex: int | None = None
361        selchild = self._container.get_selected_child()
362        if selchild is not None:
363            try:
364                selindex = self._selwidgets().index(selchild)
365            except ValueError:
366                pass
367
368        # Clear anything existing in the old sub-tab.
369        for widget in self._container.get_children():
370            if widget and widget not in {
371                    self._host_sub_tab_text,
372                    self._join_sub_tab_text,
373            }:
374                widget.delete()
375
376        if self._state.sub_tab is SubTabType.JOIN:
377            self._build_join_tab()
378        elif self._state.sub_tab is SubTabType.HOST:
379            self._build_host_tab()
380        else:
381            raise RuntimeError('Invalid state.')
382
383        # Select the new equivalent widget if there is one.
384        if selindex is not None:
385            selwidget = self._selwidgets()[selindex]
386            if selwidget:
387                ba.containerwidget(edit=self._container,
388                                   selected_child=selwidget)
389
390    def _build_join_tab(self) -> None:
391
392        ba.textwidget(parent=self._container,
393                      position=(self._c_width * 0.5, self._c_height - 140),
394                      color=(0.5, 0.46, 0.5),
395                      scale=1.5,
396                      size=(0, 0),
397                      maxwidth=250,
398                      h_align='center',
399                      v_align='center',
400                      text=ba.Lstr(resource='gatherWindow.partyCodeText'))
401
402        self._join_party_code_text = ba.textwidget(
403            parent=self._container,
404            position=(self._c_width * 0.5 - 150, self._c_height - 250),
405            flatness=1.0,
406            scale=1.5,
407            size=(300, 50),
408            editable=True,
409            description=ba.Lstr(resource='gatherWindow.partyCodeText'),
410            autoselect=True,
411            maxwidth=250,
412            h_align='left',
413            v_align='center',
414            text='')
415        btn = ba.buttonwidget(parent=self._container,
416                              size=(300, 70),
417                              label=ba.Lstr(resource='gatherWindow.'
418                                            'manualConnectText'),
419                              position=(self._c_width * 0.5 - 150,
420                                        self._c_height - 350),
421                              on_activate_call=self._join_connect_press,
422                              autoselect=True)
423        ba.textwidget(edit=self._join_party_code_text,
424                      on_return_press_call=btn.activate)
425
426    def _on_get_tickets_press(self) -> None:
427
428        if self._waiting_for_start_stop_response:
429            return
430
431        # Bring up get-tickets window and then kill ourself (we're on the
432        # overlay layer so we'd show up above it).
433        getcurrency.GetCurrencyWindow(modal=True,
434                                      origin_widget=self._get_tickets_button)
435
436    def _build_host_tab(self) -> None:
437        # pylint: disable=too-many-branches
438        # pylint: disable=too-many-statements
439
440        if _ba.get_v1_account_state() != 'signed_in':
441            ba.textwidget(parent=self._container,
442                          size=(0, 0),
443                          h_align='center',
444                          v_align='center',
445                          maxwidth=200,
446                          scale=0.8,
447                          color=(0.6, 0.56, 0.6),
448                          position=(self._c_width * 0.5, self._c_height * 0.5),
449                          text=ba.Lstr(resource='notSignedInErrorText'))
450            self._showing_not_signed_in_screen = True
451            return
452        self._showing_not_signed_in_screen = False
453
454        # At first we don't want to show anything until we've gotten a state.
455        # Update: In this situation we now simply show our existing state
456        # but give the start/stop button a loading message and disallow its
457        # use. This keeps things a lot less jumpy looking and allows selecting
458        # playlists/etc without having to wait for the server each time
459        # back to the ui.
460        if self._waiting_for_initial_state and bool(False):
461            ba.textwidget(
462                parent=self._container,
463                size=(0, 0),
464                h_align='center',
465                v_align='center',
466                maxwidth=200,
467                scale=0.8,
468                color=(0.6, 0.56, 0.6),
469                position=(self._c_width * 0.5, self._c_height * 0.5),
470                text=ba.Lstr(
471                    value='${A}...',
472                    subs=[('${A}', ba.Lstr(resource='store.loadingText'))],
473                ),
474            )
475            return
476
477        # If we're not currently hosting and hosting requires tickets,
478        # Show our count (possibly with a link to purchase more).
479        if (not self._waiting_for_initial_state
480                and self._hostingstate.party_code is None
481                and self._hostingstate.tickets_to_host_now != 0):
482            if not ba.app.ui.use_toolbars:
483                if ba.app.allow_ticket_purchases:
484                    self._get_tickets_button = ba.buttonwidget(
485                        parent=self._container,
486                        position=(self._c_width - 210 + 125,
487                                  self._c_height - 44),
488                        autoselect=True,
489                        scale=0.6,
490                        size=(120, 60),
491                        textcolor=(0.2, 1, 0.2),
492                        label=ba.charstr(ba.SpecialChar.TICKET),
493                        color=(0.65, 0.5, 0.8),
494                        on_activate_call=self._on_get_tickets_press)
495                else:
496                    self._ticket_count_text = ba.textwidget(
497                        parent=self._container,
498                        scale=0.6,
499                        position=(self._c_width - 210 + 125,
500                                  self._c_height - 44),
501                        color=(0.2, 1, 0.2),
502                        h_align='center',
503                        v_align='center')
504                # Set initial ticket count.
505                self._update_currency_ui()
506
507        v = self._c_height - 90
508        if self._hostingstate.party_code is None:
509            ba.textwidget(
510                parent=self._container,
511                size=(0, 0),
512                h_align='center',
513                v_align='center',
514                maxwidth=self._c_width * 0.9,
515                scale=0.7,
516                flatness=1.0,
517                color=(0.5, 0.46, 0.5),
518                position=(self._c_width * 0.5, v),
519                text=ba.Lstr(
520                    resource='gatherWindow.privatePartyCloudDescriptionText'))
521
522        v -= 100
523        if self._hostingstate.party_code is None:
524            # We've got no current party running; show options to set one up.
525            ba.textwidget(parent=self._container,
526                          size=(0, 0),
527                          h_align='right',
528                          v_align='center',
529                          maxwidth=200,
530                          scale=0.8,
531                          color=(0.6, 0.56, 0.6),
532                          position=(self._c_width * 0.5 - 210, v),
533                          text=ba.Lstr(resource='playlistText'))
534            self._host_playlist_button = ba.buttonwidget(
535                parent=self._container,
536                size=(400, 70),
537                color=(0.6, 0.5, 0.6),
538                textcolor=(0.8, 0.75, 0.8),
539                label=self._hostingconfig.playlist_name,
540                on_activate_call=self._playlist_press,
541                position=(self._c_width * 0.5 - 200, v - 35),
542                up_widget=self._host_sub_tab_text,
543                autoselect=True)
544
545            # If it appears we're coming back from playlist selection,
546            # re-select our playlist button.
547            if ba.app.ui.selecting_private_party_playlist:
548                ba.containerwidget(edit=self._container,
549                                   selected_child=self._host_playlist_button)
550                ba.app.ui.selecting_private_party_playlist = False
551        else:
552            # We've got a current party; show its info.
553            ba.textwidget(
554                parent=self._container,
555                size=(0, 0),
556                h_align='center',
557                v_align='center',
558                maxwidth=600,
559                scale=0.9,
560                color=(0.7, 0.64, 0.7),
561                position=(self._c_width * 0.5, v + 90),
562                text=ba.Lstr(resource='gatherWindow.partyServerRunningText'))
563            ba.textwidget(parent=self._container,
564                          size=(0, 0),
565                          h_align='center',
566                          v_align='center',
567                          maxwidth=600,
568                          scale=0.7,
569                          color=(0.7, 0.64, 0.7),
570                          position=(self._c_width * 0.5, v + 50),
571                          text=ba.Lstr(resource='gatherWindow.partyCodeText'))
572            ba.textwidget(parent=self._container,
573                          size=(0, 0),
574                          h_align='center',
575                          v_align='center',
576                          scale=2.0,
577                          color=(0.0, 1.0, 0.0),
578                          position=(self._c_width * 0.5, v + 10),
579                          text=self._hostingstate.party_code)
580
581            # Also action buttons to copy it and connect to it.
582            if ba.clipboard_is_supported():
583                cbtnoffs = 10
584                self._host_copy_button = ba.buttonwidget(
585                    parent=self._container,
586                    size=(140, 40),
587                    color=(0.6, 0.5, 0.6),
588                    textcolor=(0.8, 0.75, 0.8),
589                    label=ba.Lstr(resource='gatherWindow.copyCodeText'),
590                    on_activate_call=self._host_copy_press,
591                    position=(self._c_width * 0.5 - 150, v - 70),
592                    autoselect=True)
593            else:
594                cbtnoffs = -70
595            self._host_connect_button = ba.buttonwidget(
596                parent=self._container,
597                size=(140, 40),
598                color=(0.6, 0.5, 0.6),
599                textcolor=(0.8, 0.75, 0.8),
600                label=ba.Lstr(resource='gatherWindow.manualConnectText'),
601                on_activate_call=self._host_connect_press,
602                position=(self._c_width * 0.5 + cbtnoffs, v - 70),
603                autoselect=True)
604
605        v -= 120
606
607        # Line above the main action button:
608
609        # If we don't want to show anything until we get a state:
610        if self._waiting_for_initial_state:
611            pass
612        elif self._hostingstate.unavailable_error is not None:
613            # If hosting is unavailable, show the associated reason.
614            ba.textwidget(
615                parent=self._container,
616                size=(0, 0),
617                h_align='center',
618                v_align='center',
619                maxwidth=self._c_width * 0.9,
620                scale=0.7,
621                flatness=1.0,
622                color=(1.0, 0.0, 0.0),
623                position=(self._c_width * 0.5, v),
624                text=ba.Lstr(translate=('serverResponses',
625                                        self._hostingstate.unavailable_error)))
626        elif self._hostingstate.free_host_minutes_remaining is not None:
627            # If we've been pre-approved to start/stop for free, show that.
628            ba.textwidget(
629                parent=self._container,
630                size=(0, 0),
631                h_align='center',
632                v_align='center',
633                maxwidth=self._c_width * 0.9,
634                scale=0.7,
635                flatness=1.0,
636                color=((0.7, 0.64, 0.7) if self._hostingstate.party_code else
637                       (0.0, 1.0, 0.0)),
638                position=(self._c_width * 0.5, v),
639                text=ba.Lstr(
640                    resource='gatherWindow.startStopHostingMinutesText',
641                    subs=[(
642                        '${MINUTES}',
643                        f'{self._hostingstate.free_host_minutes_remaining:.0f}'
644                    )]))
645        else:
646            # Otherwise tell whether the free cloud server is available
647            # or will be at some point.
648            if self._hostingstate.party_code is None:
649                if self._hostingstate.tickets_to_host_now == 0:
650                    ba.textwidget(
651                        parent=self._container,
652                        size=(0, 0),
653                        h_align='center',
654                        v_align='center',
655                        maxwidth=self._c_width * 0.9,
656                        scale=0.7,
657                        flatness=1.0,
658                        color=(0.0, 1.0, 0.0),
659                        position=(self._c_width * 0.5, v),
660                        text=ba.Lstr(
661                            resource=
662                            'gatherWindow.freeCloudServerAvailableNowText'))
663                else:
664                    if self._hostingstate.minutes_until_free_host is None:
665                        ba.textwidget(
666                            parent=self._container,
667                            size=(0, 0),
668                            h_align='center',
669                            v_align='center',
670                            maxwidth=self._c_width * 0.9,
671                            scale=0.7,
672                            flatness=1.0,
673                            color=(1.0, 0.6, 0.0),
674                            position=(self._c_width * 0.5, v),
675                            text=ba.Lstr(
676                                resource=
677                                'gatherWindow.freeCloudServerNotAvailableText')
678                        )
679                    else:
680                        availmins = self._hostingstate.minutes_until_free_host
681                        ba.textwidget(
682                            parent=self._container,
683                            size=(0, 0),
684                            h_align='center',
685                            v_align='center',
686                            maxwidth=self._c_width * 0.9,
687                            scale=0.7,
688                            flatness=1.0,
689                            color=(1.0, 0.6, 0.0),
690                            position=(self._c_width * 0.5, v),
691                            text=ba.Lstr(resource='gatherWindow.'
692                                         'freeCloudServerAvailableMinutesText',
693                                         subs=[('${MINUTES}',
694                                                f'{availmins:.0f}')]))
695
696        v -= 100
697
698        if (self._waiting_for_start_stop_response
699                or self._waiting_for_initial_state):
700            btnlabel = ba.Lstr(resource='oneMomentText')
701        else:
702            if self._hostingstate.unavailable_error is not None:
703                btnlabel = ba.Lstr(
704                    resource='gatherWindow.hostingUnavailableText')
705            elif self._hostingstate.party_code is None:
706                ticon = _ba.charstr(ba.SpecialChar.TICKET)
707                nowtickets = self._hostingstate.tickets_to_host_now
708                if nowtickets > 0:
709                    btnlabel = ba.Lstr(
710                        resource='gatherWindow.startHostingPaidText',
711                        subs=[('${COST}', f'{ticon}{nowtickets}')])
712                else:
713                    btnlabel = ba.Lstr(
714                        resource='gatherWindow.startHostingText')
715            else:
716                btnlabel = ba.Lstr(resource='gatherWindow.stopHostingText')
717
718        disabled = (self._hostingstate.unavailable_error is not None
719                    or self._waiting_for_initial_state)
720        waiting = self._waiting_for_start_stop_response
721        self._host_start_stop_button = ba.buttonwidget(
722            parent=self._container,
723            size=(400, 80),
724            color=((0.6, 0.6, 0.6) if disabled else
725                   (0.5, 1.0, 0.5) if waiting else None),
726            enable_sound=False,
727            label=btnlabel,
728            textcolor=((0.7, 0.7, 0.7) if disabled else None),
729            position=(self._c_width * 0.5 - 200, v),
730            on_activate_call=self._start_stop_button_press,
731            autoselect=True)
732
733    def _playlist_press(self) -> None:
734        assert self._host_playlist_button is not None
735        self.window.playlist_select(origin_widget=self._host_playlist_button)
736
737    def _host_copy_press(self) -> None:
738        assert self._hostingstate.party_code is not None
739        ba.clipboard_set_text(self._hostingstate.party_code)
740        ba.screenmessage(ba.Lstr(resource='gatherWindow.copyCodeConfirmText'))
741
742    def _host_connect_press(self) -> None:
743        assert self._hostingstate.party_code is not None
744        self._connect_to_party_code(self._hostingstate.party_code)
745
746    def _debug_server_comm(self, msg: str) -> None:
747        if DEBUG_SERVER_COMMUNICATION:
748            print(f'PPTABCOM: {msg} at time '
749                  f'{time.time()-self._create_time:.2f}')
750
751    def _connect_to_party_code(self, code: str) -> None:
752
753        # Ignore attempted followup sends for a few seconds.
754        # (this will reset if we get a response)
755        now = time.time()
756        if (self._connect_press_time is not None
757                and now - self._connect_press_time < 5.0):
758            self._debug_server_comm(
759                'not sending private party connect (too soon)')
760            return
761        self._connect_press_time = now
762
763        self._debug_server_comm('sending private party connect')
764        _ba.add_transaction(
765            {
766                'type': 'PRIVATE_PARTY_CONNECT',
767                'expire_time': time.time() + 20,
768                'code': code
769            },
770            callback=ba.WeakCall(self._connect_response),
771        )
772        _ba.run_transactions()
773
774    def _start_stop_button_press(self) -> None:
775        if (self._waiting_for_start_stop_response
776                or self._waiting_for_initial_state):
777            return
778
779        if _ba.get_v1_account_state() != 'signed_in':
780            ba.screenmessage(ba.Lstr(resource='notSignedInErrorText'))
781            ba.playsound(ba.getsound('error'))
782            self._refresh_sub_tab()
783            return
784
785        if self._hostingstate.unavailable_error is not None:
786            ba.playsound(ba.getsound('error'))
787            return
788
789        ba.playsound(ba.getsound('click01'))
790
791        # If we're not hosting, start.
792        if self._hostingstate.party_code is None:
793
794            # If there's a ticket cost, make sure we have enough tickets.
795            if self._hostingstate.tickets_to_host_now > 0:
796                ticket_count: int | None
797                try:
798                    ticket_count = _ba.get_v1_account_ticket_count()
799                except Exception:
800                    # FIXME: should add a ba.NotSignedInError we can use here.
801                    ticket_count = None
802                ticket_cost = self._hostingstate.tickets_to_host_now
803                if ticket_count is not None and ticket_count < ticket_cost:
804                    getcurrency.show_get_tickets_prompt()
805                    ba.playsound(ba.getsound('error'))
806                    return
807            self._last_action_send_time = time.time()
808            _ba.add_transaction(
809                {
810                    'type': 'PRIVATE_PARTY_START',
811                    'config': dataclass_to_dict(self._hostingconfig),
812                    'region_pings': ba.app.net.zone_pings,
813                    'expire_time': time.time() + 20,
814                },
815                callback=ba.WeakCall(self._hosting_state_response))
816            _ba.run_transactions()
817
818        else:
819            self._last_action_send_time = time.time()
820            _ba.add_transaction(
821                {
822                    'type': 'PRIVATE_PARTY_STOP',
823                    'expire_time': time.time() + 20,
824                },
825                callback=ba.WeakCall(self._hosting_state_response))
826            _ba.run_transactions()
827        ba.playsound(ba.getsound('click01'))
828
829        self._waiting_for_start_stop_response = True
830        self._refresh_sub_tab()
831
832    def _join_connect_press(self) -> None:
833
834        # Error immediately if its an empty code.
835        code: str | None = None
836        if self._join_party_code_text:
837            code = cast(str, ba.textwidget(query=self._join_party_code_text))
838        if not code:
839            ba.screenmessage(
840                ba.Lstr(resource='internal.invalidAddressErrorText'),
841                color=(1, 0, 0))
842            ba.playsound(ba.getsound('error'))
843            return
844
845        self._connect_to_party_code(code)
846
847    def _connect_response(self, result: dict[str, Any] | None) -> None:
848        try:
849            self._connect_press_time = None
850            if result is None:
851                raise RuntimeError()
852            cresult = dataclass_from_dict(PrivatePartyConnectResult,
853                                          result,
854                                          discard_unknown_attrs=True)
855            if cresult.error is not None:
856                self._debug_server_comm('got error connect response')
857                ba.screenmessage(
858                    ba.Lstr(translate=('serverResponses', cresult.error)),
859                    (1, 0, 0))
860                ba.playsound(ba.getsound('error'))
861                return
862            self._debug_server_comm('got valid connect response')
863            assert cresult.addr is not None and cresult.port is not None
864            _ba.connect_to_party(cresult.addr, port=cresult.port)
865        except Exception:
866            self._debug_server_comm('got connect response error')
867            ba.playsound(ba.getsound('error'))
868
869    def save_state(self) -> None:
870        ba.app.ui.window_states[type(self)] = copy.deepcopy(self._state)
871
872    def restore_state(self) -> None:
873        state = ba.app.ui.window_states.get(type(self))
874        if state is None:
875            state = State()
876        assert isinstance(state, State)
877        self._state = state

The private tab in the gather UI

PrivateGatherTab(window: bastd.ui.gather.GatherWindow)
46    def __init__(self, window: GatherWindow) -> None:
47        super().__init__(window)
48        self._container: ba.Widget | None = None
49        self._state: State = State()
50        self._hostingstate = PrivateHostingState()
51        self._join_sub_tab_text: ba.Widget | None = None
52        self._host_sub_tab_text: ba.Widget | None = None
53        self._update_timer: ba.Timer | None = None
54        self._join_party_code_text: ba.Widget | None = None
55        self._c_width: float = 0.0
56        self._c_height: float = 0.0
57        self._last_hosting_state_query_time: float | None = None
58        self._waiting_for_initial_state = True
59        self._waiting_for_start_stop_response = True
60        self._host_playlist_button: ba.Widget | None = None
61        self._host_copy_button: ba.Widget | None = None
62        self._host_connect_button: ba.Widget | None = None
63        self._host_start_stop_button: ba.Widget | None = None
64        self._get_tickets_button: ba.Widget | None = None
65        self._ticket_count_text: ba.Widget | None = None
66        self._showing_not_signed_in_screen = False
67        self._create_time = time.time()
68        self._last_action_send_time: float | None = None
69        self._connect_press_time: float | None = None
70        try:
71            self._hostingconfig = self._build_hosting_config()
72        except Exception:
73            ba.print_exception('Error building hosting config')
74            self._hostingconfig = PrivateHostingConfig()
def on_activate( self, parent_widget: _ba.Widget, tab_button: _ba.Widget, region_width: float, region_height: float, region_left: float, region_bottom: float) -> _ba.Widget:
 76    def on_activate(
 77        self,
 78        parent_widget: ba.Widget,
 79        tab_button: ba.Widget,
 80        region_width: float,
 81        region_height: float,
 82        region_left: float,
 83        region_bottom: float,
 84    ) -> ba.Widget:
 85        self._c_width = region_width
 86        self._c_height = region_height - 20
 87        self._container = ba.containerwidget(
 88            parent=parent_widget,
 89            position=(region_left,
 90                      region_bottom + (region_height - self._c_height) * 0.5),
 91            size=(self._c_width, self._c_height),
 92            background=False,
 93            selection_loops_to_parent=True)
 94        v = self._c_height - 30.0
 95        self._join_sub_tab_text = ba.textwidget(
 96            parent=self._container,
 97            position=(self._c_width * 0.5 - 245, v - 13),
 98            color=(0.6, 1.0, 0.6),
 99            scale=1.3,
100            size=(200, 30),
101            maxwidth=250,
102            h_align='left',
103            v_align='center',
104            click_activate=True,
105            selectable=True,
106            autoselect=True,
107            on_activate_call=lambda: self._set_sub_tab(
108                SubTabType.JOIN,
109                playsound=True,
110            ),
111            text=ba.Lstr(resource='gatherWindow.privatePartyJoinText'))
112        self._host_sub_tab_text = ba.textwidget(
113            parent=self._container,
114            position=(self._c_width * 0.5 + 45, v - 13),
115            color=(0.6, 1.0, 0.6),
116            scale=1.3,
117            size=(200, 30),
118            maxwidth=250,
119            h_align='left',
120            v_align='center',
121            click_activate=True,
122            selectable=True,
123            autoselect=True,
124            on_activate_call=lambda: self._set_sub_tab(
125                SubTabType.HOST,
126                playsound=True,
127            ),
128            text=ba.Lstr(resource='gatherWindow.privatePartyHostText'))
129        ba.widget(edit=self._join_sub_tab_text, up_widget=tab_button)
130        ba.widget(edit=self._host_sub_tab_text,
131                  left_widget=self._join_sub_tab_text,
132                  up_widget=tab_button)
133        ba.widget(edit=self._join_sub_tab_text,
134                  right_widget=self._host_sub_tab_text)
135
136        self._update_timer = ba.Timer(1.0,
137                                      ba.WeakCall(self._update),
138                                      repeat=True,
139                                      timetype=ba.TimeType.REAL)
140
141        # Prevent taking any action until we've updated our state.
142        self._waiting_for_initial_state = True
143
144        # This will get a state query sent out immediately.
145        self._last_action_send_time = None  # Ensure we don't ignore response.
146        self._last_hosting_state_query_time = None
147        self._update()
148
149        self._set_sub_tab(self._state.sub_tab)
150
151        return self._container

Called when the tab becomes the active one.

The tab should create and return a container widget covering the specified region.

def on_deactivate(self) -> None:
223    def on_deactivate(self) -> None:
224        self._update_timer = None

Called when the tab will no longer be the active one.

def save_state(self) -> None:
869    def save_state(self) -> None:
870        ba.app.ui.window_states[type(self)] = copy.deepcopy(self._state)

Called when the parent window is saving state.

def restore_state(self) -> None:
872    def restore_state(self) -> None:
873        state = ba.app.ui.window_states.get(type(self))
874        if state is None:
875            state = State()
876        assert isinstance(state, State)
877        self._state = state

Called when the parent window is restoring state.

Inherited Members
bastd.ui.gather.GatherTab
window