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

Available sub-tabs.

JOIN = <SubTabType.JOIN: 'join'>
HOST = <SubTabType.HOST: 'host'>
Inherited Members
enum.Enum
name
value
@dataclass
class State:
41@dataclass
42class State:
43    """Our core state that persists while the app is running."""
44
45    sub_tab: SubTabType = SubTabType.JOIN

Our core state that persists while the app is running.

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

The private tab in the gather UI

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

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

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

Called when the parent window is saving state.

def restore_state(self) -> None:
990    def restore_state(self) -> None:
991        state = ba.app.ui.window_states.get(type(self))
992        if state is None:
993            state = State()
994        assert isinstance(state, State)
995        self._state = state

Called when the parent window is restoring state.

Inherited Members
bastd.ui.gather.GatherTab
window