Source code for bacommon.bs

# Released under the MIT License. See LICENSE for details.
#
"""BombSquad specific bits."""

from __future__ import annotations

import datetime
from enum import Enum
from dataclasses import dataclass, field
from typing import Annotated, override, assert_never

from efro.util import pairs_to_flat
from efro.dataclassio import ioprepped, IOAttrs, IOMultiType
from efro.message import Message, Response


[docs] @ioprepped @dataclass class PrivatePartyMessage(Message): """Message asking about info we need for private-party UI.""" need_datacode: Annotated[bool, IOAttrs('d')]
[docs] @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: return [PrivatePartyResponse]
[docs] @ioprepped @dataclass class PrivatePartyResponse(Response): """Here's that private party UI info you asked for, boss.""" success: Annotated[bool, IOAttrs('s')] tokens: Annotated[int, IOAttrs('t')] gold_pass: Annotated[bool, IOAttrs('g')] datacode: Annotated[str | None, IOAttrs('d')]
[docs] class ClassicChestAppearance(Enum): """Appearances bombsquad classic chests can have.""" UNKNOWN = 'u' DEFAULT = 'd' L1 = 'l1' L2 = 'l2' L3 = 'l3' L4 = 'l4' L5 = 'l5' L6 = 'l6' @property def pretty_name(self) -> str: """Pretty name for the chest in English.""" # pylint: disable=too-many-return-statements cls = type(self) if self is cls.UNKNOWN: return 'Unknown Chest' if self is cls.DEFAULT: return 'Chest' if self is cls.L1: return 'L1 Chest' if self is cls.L2: return 'L2 Chest' if self is cls.L3: return 'L3 Chest' if self is cls.L4: return 'L4 Chest' if self is cls.L5: return 'L5 Chest' if self is cls.L6: return 'L6 Chest' assert_never(self)
[docs] @ioprepped @dataclass class ClassicAccountLiveData: """Live account data fed to the client in the bs classic app mode."""
[docs] @dataclass class Chest: """A lovely chest.""" appearance: Annotated[ ClassicChestAppearance, IOAttrs('a', enum_fallback=ClassicChestAppearance.UNKNOWN), ] unlock_time: Annotated[datetime.datetime, IOAttrs('t')] ad_allow_time: Annotated[datetime.datetime | None, IOAttrs('at')]
[docs] class LeagueType(Enum): """Type of league we are in.""" BRONZE = 'b' SILVER = 's' GOLD = 'g' DIAMOND = 'd'
tickets: Annotated[int, IOAttrs('ti')] tokens: Annotated[int, IOAttrs('to')] gold_pass: Annotated[bool, IOAttrs('g')] remove_ads: Annotated[bool, IOAttrs('r')] achievements: Annotated[int, IOAttrs('a')] achievements_total: Annotated[int, IOAttrs('at')] league_type: Annotated[LeagueType | None, IOAttrs('lt')] league_num: Annotated[int | None, IOAttrs('ln')] league_rank: Annotated[int | None, IOAttrs('lr')] level: Annotated[int, IOAttrs('lv')] xp: Annotated[int, IOAttrs('xp')] xpmax: Annotated[int, IOAttrs('xpm')] inbox_count: Annotated[int, IOAttrs('ibc')] inbox_count_is_max: Annotated[bool, IOAttrs('ibcm')] chests: Annotated[dict[str, Chest], IOAttrs('c')]
[docs] class DisplayItemTypeID(Enum): """Type ID for each of our subclasses.""" UNKNOWN = 'u' TICKETS = 't' TOKENS = 'k' TEST = 's' CHEST = 'c'
[docs] class DisplayItem(IOMultiType[DisplayItemTypeID]): """Some amount of something that can be shown or described. Used to depict chest contents or other rewards or prices. """
[docs] @override @classmethod def get_type_id(cls) -> DisplayItemTypeID: # Require child classes to supply this themselves. If we did a # full type registry/lookup here it would require us to import # everything and would prevent lazy loading. raise NotImplementedError()
[docs] @override @classmethod def get_type(cls, type_id: DisplayItemTypeID) -> type[DisplayItem]: """Return the subclass for each of our type-ids.""" # pylint: disable=cyclic-import t = DisplayItemTypeID if type_id is t.UNKNOWN: return UnknownDisplayItem if type_id is t.TICKETS: return TicketsDisplayItem if type_id is t.TOKENS: return TokensDisplayItem if type_id is t.TEST: return TestDisplayItem if type_id is t.CHEST: return ChestDisplayItem # Important to make sure we provide all types. assert_never(type_id)
[docs] def get_description(self) -> tuple[str, list[tuple[str, str]]]: """Return a string description and subs for the item. These decriptions are baked into the DisplayItemWrapper and should be accessed from there when available. This allows clients to give descriptions even for newer display items they don't recognize. """ raise NotImplementedError()
# Implement fallbacks so client can digest item lists even if they # contain unrecognized stuff. DisplayItemWrapper contains basic # baked down info that they can still use in such cases.
[docs] @override @classmethod def get_unknown_type_fallback(cls) -> DisplayItem: return UnknownDisplayItem()
[docs] @ioprepped @dataclass class UnknownDisplayItem(DisplayItem): """Something we don't know how to display."""
[docs] @override @classmethod def get_type_id(cls) -> DisplayItemTypeID: return DisplayItemTypeID.UNKNOWN
[docs] @override def get_description(self) -> tuple[str, list[tuple[str, str]]]: import logging # Make noise but don't break. logging.exception( 'UnknownDisplayItem.get_description() should never be called.' ' Always access descriptions on the DisplayItemWrapper.' ) return 'Unknown', []
[docs] @ioprepped @dataclass class TicketsDisplayItem(DisplayItem): """Some amount of tickets.""" count: Annotated[int, IOAttrs('c')]
[docs] @override @classmethod def get_type_id(cls) -> DisplayItemTypeID: return DisplayItemTypeID.TICKETS
[docs] @override def get_description(self) -> tuple[str, list[tuple[str, str]]]: return '${C} Tickets', [('${C}', str(self.count))]
[docs] @ioprepped @dataclass class TokensDisplayItem(DisplayItem): """Some amount of tokens.""" count: Annotated[int, IOAttrs('c')]
[docs] @override @classmethod def get_type_id(cls) -> DisplayItemTypeID: return DisplayItemTypeID.TOKENS
[docs] @override def get_description(self) -> tuple[str, list[tuple[str, str]]]: return '${C} Tokens', [('${C}', str(self.count))]
[docs] @ioprepped @dataclass class TestDisplayItem(DisplayItem): """Fills usable space for a display-item - good for calibration."""
[docs] @override @classmethod def get_type_id(cls) -> DisplayItemTypeID: return DisplayItemTypeID.TEST
[docs] @override def get_description(self) -> tuple[str, list[tuple[str, str]]]: return 'Test Display Item Here', []
[docs] @ioprepped @dataclass class ChestDisplayItem(DisplayItem): """Display a chest.""" appearance: Annotated[ClassicChestAppearance, IOAttrs('a')]
[docs] @override @classmethod def get_type_id(cls) -> DisplayItemTypeID: return DisplayItemTypeID.CHEST
[docs] @override def get_description(self) -> tuple[str, list[tuple[str, str]]]: return self.appearance.pretty_name, []
[docs] @ioprepped @dataclass class DisplayItemWrapper: """Wraps a DisplayItem and common info.""" item: Annotated[DisplayItem, IOAttrs('i')] description: Annotated[str, IOAttrs('d')] description_subs: Annotated[list[str] | None, IOAttrs('s')]
[docs] @classmethod def for_display_item(cls, item: DisplayItem) -> DisplayItemWrapper: """Convenience method to wrap a DisplayItem.""" desc, subs = item.get_description() return DisplayItemWrapper(item, desc, pairs_to_flat(subs))
[docs] @ioprepped @dataclass class ChestInfoMessage(Message): """Request info about a chest.""" chest_id: Annotated[str, IOAttrs('i')]
[docs] @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: return [ChestInfoResponse]
[docs] @ioprepped @dataclass class ChestInfoResponse(Response): """Here's that chest info you asked for, boss."""
[docs] @dataclass class Chest: """A lovely chest."""
[docs] @dataclass class PrizeSet: """A possible set of prizes for this chest.""" weight: Annotated[float, IOAttrs('w')] contents: Annotated[list[DisplayItemWrapper], IOAttrs('c')]
appearance: Annotated[ ClassicChestAppearance, IOAttrs('a', enum_fallback=ClassicChestAppearance.UNKNOWN), ] # How much it costs to unlock *now*. unlock_tokens: Annotated[int, IOAttrs('tk')] # When it unlocks on its own. unlock_time: Annotated[datetime.datetime, IOAttrs('t')] # Possible prizes we contain. prizesets: Annotated[list[PrizeSet], IOAttrs('p')] # Are ads allowed now? ad_allow: Annotated[bool, IOAttrs('aa')]
chest: Annotated[Chest | None, IOAttrs('c')] user_tokens: Annotated[int | None, IOAttrs('t')]
[docs] @ioprepped @dataclass class ChestActionMessage(Message): """Request action about a chest."""
[docs] class Action(Enum): """Types of actions we can request.""" # Unlocking (for free or with tokens). UNLOCK = 'u' # Watched an ad to reduce wait. AD = 'ad'
action: Annotated[Action, IOAttrs('a')] # Tokens we are paying (only applies to unlock). token_payment: Annotated[int, IOAttrs('t')] chest_id: Annotated[str, IOAttrs('i')]
[docs] @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: return [ChestActionResponse]
[docs] @ioprepped @dataclass class ChestActionResponse(Response): """Here's the results of that action you asked for, boss.""" # Tokens that were actually charged. tokens_charged: Annotated[int, IOAttrs('t')] = 0 # If present, signifies the chest has been opened and we should show # the user this stuff that was in it. contents: Annotated[list[DisplayItemWrapper] | None, IOAttrs('c')] = None # If contents are present, which of the chest's prize-sets they # represent. prizeindex: Annotated[int, IOAttrs('i')] = 0 # Printable error if something goes wrong. error: Annotated[str | None, IOAttrs('e')] = None # Printable warning. Shown in orange with an error sound. Does not # mean the action failed; only that there's something to tell the # users such as 'It looks like you are faking ad views; stop it or # you won't have ad options anymore.' warning: Annotated[str | None, IOAttrs('w')] = None # Printable success message. Shown in green with a cash-register # sound. Can be used for things like successful wait reductions via # ad views. success_msg: Annotated[str | None, IOAttrs('s')] = None
[docs] class ClientUITypeID(Enum): """Type ID for each of our subclasses.""" UNKNOWN = 'u' BASIC = 'b'
[docs] class ClientUI(IOMultiType[ClientUITypeID]): """Defines some user interface on the client."""
[docs] @override @classmethod def get_type_id(cls) -> ClientUITypeID: # Require child classes to supply this themselves. If we did a # full type registry/lookup here it would require us to import # everything and would prevent lazy loading. raise NotImplementedError()
[docs] @override @classmethod def get_type(cls, type_id: ClientUITypeID) -> type[ClientUI]: """Return the subclass for each of our type-ids.""" # pylint: disable=cyclic-import out: type[ClientUI] t = ClientUITypeID if type_id is t.UNKNOWN: out = UnknownClientUI elif type_id is t.BASIC: out = BasicClientUI else: # Important to make sure we provide all types. assert_never(type_id) return out
[docs] @override @classmethod def get_unknown_type_fallback(cls) -> ClientUI: # If we encounter some future message type we don't know # anything about, drop in a placeholder. return UnknownClientUI()
[docs] @ioprepped @dataclass class UnknownClientUI(ClientUI): """Fallback type for unrecognized entries."""
[docs] @override @classmethod def get_type_id(cls) -> ClientUITypeID: return ClientUITypeID.UNKNOWN
[docs] class BasicClientUIComponentTypeID(Enum): """Type ID for each of our subclasses.""" UNKNOWN = 'u' TEXT = 't' LINK = 'l' BS_CLASSIC_TOURNEY_RESULT = 'ct' DISPLAY_ITEMS = 'di' EXPIRE_TIME = 'd'
[docs] class BasicClientUIComponent(IOMultiType[BasicClientUIComponentTypeID]): """Top level class for our multitype."""
[docs] @override @classmethod def get_type_id(cls) -> BasicClientUIComponentTypeID: # Require child classes to supply this themselves. If we did a # full type registry/lookup here it would require us to import # everything and would prevent lazy loading. raise NotImplementedError()
[docs] @override @classmethod def get_type( cls, type_id: BasicClientUIComponentTypeID ) -> type[BasicClientUIComponent]: """Return the subclass for each of our type-ids.""" # pylint: disable=cyclic-import t = BasicClientUIComponentTypeID if type_id is t.UNKNOWN: return BasicClientUIComponentUnknown if type_id is t.TEXT: return BasicClientUIComponentText if type_id is t.LINK: return BasicClientUIComponentLink if type_id is t.BS_CLASSIC_TOURNEY_RESULT: return BasicClientUIBsClassicTourneyResult if type_id is t.DISPLAY_ITEMS: return BasicClientUIDisplayItems if type_id is t.EXPIRE_TIME: return BasicClientUIExpireTime # Important to make sure we provide all types. assert_never(type_id)
[docs] @override @classmethod def get_unknown_type_fallback(cls) -> BasicClientUIComponent: # If we encounter some future message type we don't know # anything about, drop in a placeholder. return BasicClientUIComponentUnknown()
[docs] @ioprepped @dataclass class BasicClientUIComponentUnknown(BasicClientUIComponent): """An unknown basic client component type. In practice these should never show up since the master-server generates these on the fly for the client and so should not send clients one they can't digest. """
[docs] @override @classmethod def get_type_id(cls) -> BasicClientUIComponentTypeID: return BasicClientUIComponentTypeID.UNKNOWN
[docs] @ioprepped @dataclass class BasicClientUIComponentText(BasicClientUIComponent): """Show some text in the inbox message.""" text: Annotated[str, IOAttrs('t')] subs: Annotated[list[str], IOAttrs('s', store_default=False)] = field( default_factory=list ) scale: Annotated[float, IOAttrs('sc', store_default=False)] = 1.0 color: Annotated[ tuple[float, float, float, float], IOAttrs('c', store_default=False) ] = (1.0, 1.0, 1.0, 1.0) spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0 spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0
[docs] @override @classmethod def get_type_id(cls) -> BasicClientUIComponentTypeID: return BasicClientUIComponentTypeID.TEXT
[docs] @ioprepped @dataclass class BasicClientUIBsClassicTourneyResult(BasicClientUIComponent): """Show info about a classic tourney.""" tournament_id: Annotated[str, IOAttrs('t')] game: Annotated[str, IOAttrs('g')] players: Annotated[int, IOAttrs('p')] rank: Annotated[int, IOAttrs('r')] trophy: Annotated[str | None, IOAttrs('tr')] prizes: Annotated[list[DisplayItemWrapper], IOAttrs('pr')]
[docs] @override @classmethod def get_type_id(cls) -> BasicClientUIComponentTypeID: return BasicClientUIComponentTypeID.BS_CLASSIC_TOURNEY_RESULT
[docs] @ioprepped @dataclass class BasicClientUIDisplayItems(BasicClientUIComponent): """Show some display-items.""" items: Annotated[list[DisplayItemWrapper], IOAttrs('d')] width: Annotated[float, IOAttrs('w')] = 100.0 spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0 spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0
[docs] @override @classmethod def get_type_id(cls) -> BasicClientUIComponentTypeID: return BasicClientUIComponentTypeID.DISPLAY_ITEMS
[docs] @ioprepped @dataclass class BasicClientUIExpireTime(BasicClientUIComponent): """Show expire-time.""" time: Annotated[datetime.datetime, IOAttrs('d')] spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0 spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0
[docs] @override @classmethod def get_type_id(cls) -> BasicClientUIComponentTypeID: return BasicClientUIComponentTypeID.EXPIRE_TIME
[docs] @ioprepped @dataclass class BasicClientUI(ClientUI): """A basic UI for the client."""
[docs] class ButtonLabel(Enum): """Distinct button labels we support.""" UNKNOWN = 'u' OK = 'o' APPLY = 'a' CANCEL = 'c' ACCEPT = 'ac' DECLINE = 'dn' IGNORE = 'ig' CLAIM = 'cl' DISCARD = 'd'
[docs] class InteractionStyle(Enum): """Overall interaction styles we support.""" UNKNOWN = 'u' BUTTON_POSITIVE = 'p' BUTTON_POSITIVE_NEGATIVE = 'pn'
components: Annotated[list[BasicClientUIComponent], IOAttrs('s')] interaction_style: Annotated[ InteractionStyle, IOAttrs('i', enum_fallback=InteractionStyle.UNKNOWN) ] = InteractionStyle.BUTTON_POSITIVE button_label_positive: Annotated[ ButtonLabel, IOAttrs('p', enum_fallback=ButtonLabel.UNKNOWN) ] = ButtonLabel.OK button_label_negative: Annotated[ ButtonLabel, IOAttrs('n', enum_fallback=ButtonLabel.UNKNOWN) ] = ButtonLabel.CANCEL
[docs] @override @classmethod def get_type_id(cls) -> ClientUITypeID: return ClientUITypeID.BASIC
[docs] def contains_unknown_elements(self) -> bool: """Whether something within us is an unknown type or enum.""" return ( self.interaction_style is self.InteractionStyle.UNKNOWN or self.button_label_positive is self.ButtonLabel.UNKNOWN or self.button_label_negative is self.ButtonLabel.UNKNOWN or any( c.get_type_id() is BasicClientUIComponentTypeID.UNKNOWN for c in self.components ) )
[docs] @ioprepped @dataclass class ClientUIWrapper: """Wrapper for a ClientUI and its common data.""" id: Annotated[str, IOAttrs('i')] createtime: Annotated[datetime.datetime, IOAttrs('c')] ui: Annotated[ClientUI, IOAttrs('e')]
[docs] @ioprepped @dataclass class InboxRequestMessage(Message): """Message requesting our inbox."""
[docs] @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: return [InboxRequestResponse]
[docs] @ioprepped @dataclass class InboxRequestResponse(Response): """Here's that inbox contents you asked for, boss.""" wrappers: Annotated[list[ClientUIWrapper], IOAttrs('w')] # Printable error if something goes wrong. error: Annotated[str | None, IOAttrs('e')] = None
[docs] class ClientUIAction(Enum): """Types of actions we can run.""" BUTTON_PRESS_POSITIVE = 'p' BUTTON_PRESS_NEGATIVE = 'n'
[docs] class ClientEffectTypeID(Enum): """Type ID for each of our subclasses.""" UNKNOWN = 'u' SCREEN_MESSAGE = 'm' SOUND = 's' DELAY = 'd'
[docs] class ClientEffect(IOMultiType[ClientEffectTypeID]): """Something that can happen on the client. This can include screen messages, sounds, visual effects, etc. """
[docs] @override @classmethod def get_type_id(cls) -> ClientEffectTypeID: # Require child classes to supply this themselves. If we did a # full type registry/lookup here it would require us to import # everything and would prevent lazy loading. raise NotImplementedError()
[docs] @override @classmethod def get_type(cls, type_id: ClientEffectTypeID) -> type[ClientEffect]: """Return the subclass for each of our type-ids.""" # pylint: disable=cyclic-import t = ClientEffectTypeID if type_id is t.UNKNOWN: return ClientEffectUnknown if type_id is t.SCREEN_MESSAGE: return ClientEffectScreenMessage if type_id is t.SOUND: return ClientEffectSound if type_id is t.DELAY: return ClientEffectDelay # Important to make sure we provide all types. assert_never(type_id)
[docs] @override @classmethod def get_unknown_type_fallback(cls) -> ClientEffect: # If we encounter some future message type we don't know # anything about, drop in a placeholder. return ClientEffectUnknown()
[docs] @ioprepped @dataclass class ClientEffectUnknown(ClientEffect): """Fallback substitute for types we don't recognize."""
[docs] @override @classmethod def get_type_id(cls) -> ClientEffectTypeID: return ClientEffectTypeID.UNKNOWN
[docs] @ioprepped @dataclass class ClientEffectScreenMessage(ClientEffect): """Display a screen-message.""" message: Annotated[str, IOAttrs('m')] subs: Annotated[list[str], IOAttrs('s')] color: Annotated[tuple[float, float, float], IOAttrs('c')] = (1.0, 1.0, 1.0)
[docs] @override @classmethod def get_type_id(cls) -> ClientEffectTypeID: return ClientEffectTypeID.SCREEN_MESSAGE
[docs] @ioprepped @dataclass class ClientEffectSound(ClientEffect): """Play a sound."""
[docs] class Sound(Enum): """Sounds that can be made alongside the message.""" UNKNOWN = 'u' CASH_REGISTER = 'c' ERROR = 'e' POWER_DOWN = 'p' GUN_COCKING = 'g'
sound: Annotated[Sound, IOAttrs('s', enum_fallback=Sound.UNKNOWN)] volume: Annotated[float, IOAttrs('v')] = 1.0
[docs] @override @classmethod def get_type_id(cls) -> ClientEffectTypeID: return ClientEffectTypeID.SOUND
[docs] @ioprepped @dataclass class ClientEffectDelay(ClientEffect): """Delay effect processing.""" seconds: Annotated[float, IOAttrs('s')]
[docs] @override @classmethod def get_type_id(cls) -> ClientEffectTypeID: return ClientEffectTypeID.DELAY
[docs] @ioprepped @dataclass class ClientUIActionMessage(Message): """Do something to a client ui.""" id: Annotated[str, IOAttrs('i')] action: Annotated[ClientUIAction, IOAttrs('a')]
[docs] @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: return [ClientUIActionResponse]
[docs] @ioprepped @dataclass class ClientUIActionResponse(Response): """Did something to that inbox entry, boss."""
[docs] class ErrorType(Enum): """Types of errors that may have occurred.""" # Probably a future error type we don't recognize. UNKNOWN = 'u' # Something went wrong on the server, but specifics are not # relevant. INTERNAL = 'i' # The entry expired on the server. In various cases such as 'ok' # buttons this can generally be ignored. EXPIRED = 'e'
error_type: Annotated[ ErrorType | None, IOAttrs('et', enum_fallback=ErrorType.UNKNOWN) ] # User facing error message in the case of errors. error_message: Annotated[str | None, IOAttrs('em')] effects: Annotated[list[ClientEffect], IOAttrs('fx')]