# 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 BasicClientUIComponentLink(BasicClientUIComponent):
"""Show a link in the inbox message."""
url: Annotated[str, IOAttrs('u')]
label: Annotated[str, IOAttrs('l')]
subs: Annotated[list[str], IOAttrs('s', store_default=False)] = field(
default_factory=list
)
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.LINK
[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 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')]