diff --git a/.efrocachemap b/.efrocachemap index 8fb8c98b..8c426274 100644 --- a/.efrocachemap +++ b/.efrocachemap @@ -420,10 +420,10 @@ "assets/build/ba_data/audio/zoeOw.ogg": "https://files.ballistica.net/cache/ba1/14/f1/4f2995d78fc20dd79dfb39c5d554", "assets/build/ba_data/audio/zoePickup01.ogg": "https://files.ballistica.net/cache/ba1/57/ac/6ed0caecd25dc23688debed24c45", "assets/build/ba_data/audio/zoeScream01.ogg": "https://files.ballistica.net/cache/ba1/32/08/38dac4a79ab2acee76a75d32a310", - "assets/build/ba_data/data/langdata.json": "https://files.ballistica.net/cache/ba1/97/0d/5879ed6101537373f87deeccf860", + "assets/build/ba_data/data/langdata.json": "https://files.ballistica.net/cache/ba1/37/c5/fefc3b664420efec78431704855e", "assets/build/ba_data/data/languages/arabic.json": "https://files.ballistica.net/cache/ba1/0f/0e/7184059414320d32104463e41038", "assets/build/ba_data/data/languages/belarussian.json": "https://files.ballistica.net/cache/ba1/e2/58/c2c5964370df118c51528dc4bfa2", - "assets/build/ba_data/data/languages/chinese.json": "https://files.ballistica.net/cache/ba1/0c/40/6222070dc95b29e42b77dd105357", + "assets/build/ba_data/data/languages/chinese.json": "https://files.ballistica.net/cache/ba1/d8/b8/a38187a1dfba81a42ddfbf2932be", "assets/build/ba_data/data/languages/chinesetraditional.json": "https://files.ballistica.net/cache/ba1/f9/78/95903907372008cdce27d948b797", "assets/build/ba_data/data/languages/croatian.json": "https://files.ballistica.net/cache/ba1/66/bf/6e98398016da261296b8c306560e", "assets/build/ba_data/data/languages/czech.json": "https://files.ballistica.net/cache/ba1/b6/67/633c424cc32e5c4afbd188d3a908", @@ -440,7 +440,7 @@ "assets/build/ba_data/data/languages/indonesian.json": "https://files.ballistica.net/cache/ba1/87/e5/a10ddd73cfb7996bbd576032db6a", "assets/build/ba_data/data/languages/italian.json": "https://files.ballistica.net/cache/ba1/34/0f/dd2e311024ceb913b8489b823fdc", "assets/build/ba_data/data/languages/korean.json": "https://files.ballistica.net/cache/ba1/26/8d/bf9cc8db2cc71b69e789898e1093", - "assets/build/ba_data/data/languages/persian.json": "https://files.ballistica.net/cache/ba1/4f/23/b9692ca7f9407972254fb245ffb0", + "assets/build/ba_data/data/languages/persian.json": "https://files.ballistica.net/cache/ba1/d8/b7/9098f0cb25088d233541490e3e68", "assets/build/ba_data/data/languages/polish.json": "https://files.ballistica.net/cache/ba1/2e/17/fb3e7ed77fa54427b434b1791793", "assets/build/ba_data/data/languages/portuguese.json": "https://files.ballistica.net/cache/ba1/6c/04/a528a4df9364ad4f0261cbc83f0a", "assets/build/ba_data/data/languages/romanian.json": "https://files.ballistica.net/cache/ba1/82/12/57bf144e12be229a9b70da9c45cb", diff --git a/.idea/dictionaries/ericf.xml b/.idea/dictionaries/ericf.xml index d055fb1d..ac3ab77e 100644 --- a/.idea/dictionaries/ericf.xml +++ b/.idea/dictionaries/ericf.xml @@ -1401,6 +1401,7 @@ mpath mrmaxmeier msbuild + msgdict mshell msvccompiler msvcp @@ -1914,6 +1915,7 @@ rstr rtnetlink rtxt + rtypes runmypy runonly runpy @@ -2140,6 +2142,7 @@ storeitemui storename strftime + stringified stringprep stringptr strippable diff --git a/ballisticacore-cmake/.idea/dictionaries/ericf.xml b/ballisticacore-cmake/.idea/dictionaries/ericf.xml index a8be275e..3913bb4c 100644 --- a/ballisticacore-cmake/.idea/dictionaries/ericf.xml +++ b/ballisticacore-cmake/.idea/dictionaries/ericf.xml @@ -638,6 +638,7 @@ moreso mqrspec msaa + msgdict mult multing multipass @@ -882,6 +883,7 @@ rresult rscode rsgc + rtypes runnables rvec rvel @@ -990,6 +992,7 @@ strcpy strdup stringi + stringified strlen strs strtof diff --git a/docs/ba_module.md b/docs/ba_module.md index 96705057..66b9096b 100644 --- a/docs/ba_module.md +++ b/docs/ba_module.md @@ -1,5 +1,5 @@ -

last updated on 2021-08-23 for Ballistica version 1.6.5 build 20391

+

last updated on 2021-09-02 for Ballistica version 1.6.5 build 20391

This page documents the Python classes and functions in the 'ba' module, which are the ones most relevant to modding in Ballistica. If you come across something you feel should be included here or could be better explained, please let me know. Happy modding!


diff --git a/tests/test_efro/test_message.py b/tests/test_efro/test_message.py index f4c5c2fc..a542edad 100644 --- a/tests/test_efro/test_message.py +++ b/tests/test_efro/test_message.py @@ -5,30 +5,85 @@ from __future__ import annotations from typing import TYPE_CHECKING +from dataclasses import dataclass -# import pytest +import pytest -from efro.message import MessageProtocol, MessageSender +from efro.dataclassio import ioprepped +from efro.message import (Message, MessageProtocol, MessageSender, + MessageReceiver) if TYPE_CHECKING: - pass + from typing import List, Type + + +@ioprepped +@dataclass +class _TestMessage1(Message): + """Just testing.""" + ival: int + + @classmethod + def get_response_types(cls) -> List[Type[Message]]: + return [_TestMessage2] + + +@ioprepped +@dataclass +class _TestMessage2(Message): + """Just testing.""" + bval: bool + + +def test_protocol_creation() -> None: + """Test protocol creation.""" + + # This should fail because _TestMessage1 can return _TestMessage2 which + # is not given an id here. + with pytest.raises(ValueError): + _protocol = MessageProtocol(message_types={1: _TestMessage1}) + + _protocol = MessageProtocol(message_types={ + 1: _TestMessage1, + 2: _TestMessage2 + }) def test_message_sending() -> None: """Test simple message sending.""" - protocol = MessageProtocol(message_types={}) + protocol = MessageProtocol(message_types={ + 1: _TestMessage1, + 2: _TestMessage2 + }) - class TestClass: - """Test.""" + class TestClassS: + """For testing send functionality.""" + msg = MessageSender(protocol) + + def __init__(self, receiver: TestClassR) -> None: + self._receiver = receiver + + @msg.send_raw_handler def _send_raw_message(self, data: bytes) -> bytes: """Test.""" - print(f'WOULD SEND RAW MSG OF SIZE {len(data)}') + print(f'WOULD SEND RAW MSG OF SIZE: {len(data)}') return b'' - msg = MessageSender(protocol, _send_raw_message) + class TestClassR: + """For testing receive functionality.""" - obj = TestClass() - print(f'MADE TEST OBJ {obj}') - obj.msg.send('foo') + receiver = MessageReceiver(protocol) + + @receiver.handler + def handle_test_message(self, msg: Message) -> Message: + """Test.""" + del msg # Unused + print('Hello from test message 1 handler!') + return _TestMessage2(bval=True) + + obj_r = TestClassR() + obj_s = TestClassS(receiver=obj_r) + print(f'MADE TEST OBJS {obj_s} and {obj_r}') + obj_s.msg.send(_TestMessage1(ival=0)) diff --git a/tools/efro/dataclassio.py b/tools/efro/dataclassio.py index 5b9b449f..c116e9ec 100644 --- a/tools/efro/dataclassio.py +++ b/tools/efro/dataclassio.py @@ -302,6 +302,12 @@ def ioprepped(cls: Type[T]) -> Type[T]: return cls +def is_ioprepped_dataclass(obj: Any) -> bool: + """Return whether the obj is an ioprepped dataclass type or instance.""" + cls = obj if isinstance(obj, type) else type(obj) + return dataclasses.is_dataclass(cls) and hasattr(cls, PREP_ATTR) + + @dataclasses.dataclass class PrepData: """Data we prepare and cache for a class during prep. diff --git a/tools/efro/entity/__init__.py b/tools/efro/entity/__init__.py index 33aea854..ce1e25bd 100644 --- a/tools/efro/entity/__init__.py +++ b/tools/efro/entity/__init__.py @@ -2,13 +2,19 @@ # """Entity functionality. +**************************************************************************** +NOTE: This is largely being replaced by dataclassio, which offers similar +functionality in a cleaner way. Ideally we should remove this completely at +some point, but for now we should at least try to avoid using it in new code. +**************************************************************************** + A system for defining structured data, supporting both static and runtime type safety, serialization, efficient/sparse storage, per-field value limits, etc. This is a heavyweight option in comparison to things such as dataclasses, but the increased features can make the overhead worth it for certain use cases. -Advantages compared to nested dataclasses: +Advantages compared to raw nested dataclasses: - Field names separated from their data representation so can get more concise json data, change variable names while preserving back-compat, etc. - Can wrap and preserve unmapped data (so fields can be added to new versions @@ -16,7 +22,7 @@ Advantages compared to nested dataclasses: - Incorrectly typed data is caught at runtime (for dataclasses we rely on type-checking and explicit validation calls) -Disadvantages compared to nested dataclasses: +Disadvantages compared to raw nested dataclasses: - More complex to use - Significantly more heavyweight (roughly 10 times slower in quick tests) - Can't currently be initialized in constructors (this would probably require diff --git a/tools/efro/error.py b/tools/efro/error.py index bee7e500..7adc4cc8 100644 --- a/tools/efro/error.py +++ b/tools/efro/error.py @@ -54,6 +54,10 @@ class RemoteError(Exception): This occurs when communication succeeds but another type of error occurs remotely. The error string can consist of a remote stack trace or a simple message depending on the context. + + Depending on the situation, more specific error types such as CleanError + may be raised due to the remote error, so this one is considered somewhat + of a catch-all. """ def __str__(self) -> str: diff --git a/tools/efro/json.py b/tools/efro/json.py index 9fb52282..02d882de 100644 --- a/tools/efro/json.py +++ b/tools/efro/json.py @@ -11,6 +11,10 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any +# NOTE: This functionality is duplicated in a newer, cleaner way in +# dataclassio. We should consider deprecating this along with entity at +# some point. + # Special attr we included for our extended type information # (extended-json-type) TYPE_TAG = '_xjtp' diff --git a/tools/efro/message.py b/tools/efro/message.py index af77fbdd..a6e41332 100644 --- a/tools/efro/message.py +++ b/tools/efro/message.py @@ -6,40 +6,137 @@ Supports static typing for message types and possible return types. from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypeVar +from dataclasses import dataclass +from enum import Enum +import json + +from typing_extensions import Annotated + +from efro.dataclassio import (ioprepped, is_ioprepped_dataclass, IOAttrs, + dataclass_to_dict) if TYPE_CHECKING: - from typing import Dict, Type, Tuple, List, Any, Callable, Optional + from typing import Dict, Type, Tuple, List, Any, Callable, Optional, Set from efro.error import TransportError +TM = TypeVar('TM', bound='MessageSender') + + +class RemoteErrorType(Enum): + """Type of error that occurred in remote message handling.""" + OTHER = 0 + CLEAN = 1 + + +class Message: + """Base class for messages and their responses.""" + + @classmethod + def get_response_types(cls) -> List[Type[Message]]: + """Return all message types this Message can result in when sent. + Messages intended only for response types can leave this empty. + Note: RemoteErrorMessage is handled transparently and does not + need to be specified here. + """ + return [] + + +@ioprepped +@dataclass +class RemoteErrorMessage(Message): + """Message saying some error has occurred on the other end.""" + error_message: Annotated[str, IOAttrs('m')] + error_type: Annotated[RemoteErrorType, IOAttrs('t')] + class MessageProtocol: """Wrangles a set of message types, formats, and response types. - Both endpoints must be using the same Protocol (even if one side - is newer) for communication to succeed. + Both endpoints must be using a compatible Protocol for communication + to succeed. To maintain Protocol compatibility between revisions, + all message types must retain the same id, message attr storage names must + not change, newly added attrs must have default values, etc. """ - def __init__( - self, - message_types: Dict[int, Tuple[Type, List[Type]]], - type_key: str = '_t', - ) -> None: + def __init__(self, + message_types: Dict[int, Type[Message]], + type_key: Optional[str] = None, + preserve_clean_errors: bool = True, + remote_stack_traces: bool = False) -> None: """Create a protocol with a given configuration. Each entry for message_types should contain an ID, a message type, and all possible response types. + + If 'type_key' is provided, the message type ID is stored as the + provided key in the message dict; otherwise it will be stored as + part of a top level dict with the message payload appearing as a + child dict. This is mainly for backwards compatibility. + + If 'preserve_clean_errors' is True, efro.error.CleanError errors + on the remote end will result in the same error raised locally. + All other Exception types come across as efro.error.RemoteError. + + If 'remote_stack_traces' is True, stringified remote stack traces will + be included in the RemoteError. This should only be enabled in cases + where the client is trusted. """ - self._message_types = message_types + self._message_types_by_id: Dict[int, Type[Message]] = {} + self._message_ids_by_type: Dict[Type[Message], int] = {} + for m_id, m_type in message_types.items(): + + # Make sure only valid message types were passed and each + # id was assigned only once. + assert isinstance(m_id, int) + assert (is_ioprepped_dataclass(m_type) + and issubclass(m_type, Message)) + assert self._message_types_by_id.get(m_id) is None + + self._message_types_by_id[m_id] = m_type + self._message_ids_by_type[m_type] = m_id + + # Make sure all return types are valid and have been assigned + # an ID as well. + if __debug__: + all_response_types: Set[Type[Message]] = set() + for m_id, m_type in message_types.items(): + m_rtypes = m_type.get_response_types() + assert isinstance(m_rtypes, list) + all_response_types.update(m_rtypes) + for cls in all_response_types: + assert is_ioprepped_dataclass(cls) and issubclass(cls, Message) + if cls not in self._message_ids_by_type: + raise ValueError(f'Possible response type {cls}' + f' was not included in message_types.') + self._type_key = type_key + self._preserve_clean_errors = preserve_clean_errors + self._remote_stack_traces = remote_stack_traces - def message_encode(self, message: Any) -> bytes: + def message_encode(self, message: Message) -> bytes: """Encode a message to bytes for sending.""" - print(f'WOULD ENCODE MSG: {message} TO RAW.') - return b'' - def message_decode(self, data: bytes) -> Any: + m_id = self._message_ids_by_type.get(type(message)) + if m_id is None: + raise TypeError(f'Message type is not registered in protocol:' + f' {type(message)}') + msgdict = dataclass_to_dict(message) + + # Encode type as part of the message dict if desired + # (for legacy compatibility). + if self._type_key is not None: + if self._type_key in msgdict: + raise RuntimeError(f'Type-key {self._type_key}' + f' found in msg of type {type(message)}') + msgdict[self._type_key] = m_id + out = msgdict + else: + out = {'m': msgdict, 't': m_id} + return json.dumps(out, separators=(',', ':')).encode() + + def message_decode(self, data: bytes) -> Message: """Decode a message from bytes.""" print(f'WOULD DECODE MSG FROM RAW: {str(data)}') - return 'foo' + return Message() def create_sender_module(self, classname: str) -> str: """"Create a Python module defining a MessageSender subclass. @@ -66,37 +163,51 @@ class MessageSender: Example: class MyClass: + msg = MyMessageSender(some_protocol) + @msg.send_raw_handler def send_raw_message(self, message: bytes) -> bytes: # Actually send the message here. - msg = MyMessageSender(some_protocol, send_raw_message) - # MyMessageSender class should provide overloads for send(), send_bg(), # etc. to ensure all sending happens with valid types. obj = MyClass() obj.msg.send(SomeMessageType()) """ - def __init__( - self, protocol: MessageProtocol, - send_raw_message_call: Optional[Callable[[Any, bytes], - bytes]]) -> None: + def __init__(self, protocol: MessageProtocol) -> None: self._protocol = protocol - self._send_raw_message_call = send_raw_message_call + self._send_raw_message_call: Optional[Callable[[Any, bytes], + bytes]] = None + self._bound_obj: Any = None - def __get__(self, obj: Any, type_in: Any = None) -> Any: + def __get__(self: TM, obj: Any, type_in: Any = None) -> TM: if obj is None: raise RuntimeError('Must be called on an instance, not a type.') - print(f'HELLO FROM GET {obj}') - return self - def send(self, message: Any) -> Any: + # Return a copy of ourself bound to the instance we were called from. + bound_sender = type(self)(self._protocol) + bound_sender._send_raw_message_call = self._send_raw_message_call + bound_sender._bound_obj = obj + + return bound_sender + + def send_raw_handler( + self, call: Callable[[Any, bytes], + bytes]) -> Callable[[Any, bytes], bytes]: + """Function decorator for setting raw send method.""" + assert self._send_raw_message_call is None + self._send_raw_message_call = call + return call + + def send(self, message: Message) -> Any: """Send a message and receive a response. Will encode the message for transport and call dispatch_raw_message() """ if self._send_raw_message_call is None: - raise RuntimeError('Unimplemented!') + raise RuntimeError('send() is unimplemented for this type.') + if self._bound_obj is None: + raise RuntimeError('Cannot call on an unbound instance.') encoded = self._protocol.message_encode(message) return self._send_raw_message_call(None, encoded) @@ -127,7 +238,7 @@ class MessageReceiver: # MyMessageReceiver should provide overloads to register_handler() # to ensure all registered handlers have valid types/return-types. - @receiver.register_handler + @receiver.handler def handle_some_message_type(self, message: SomeType) -> AnotherType: # Deal with this message type here. @@ -140,17 +251,25 @@ class MessageReceiver: """ def __init__(self, protocol: MessageProtocol) -> None: - pass + self._protocol = protocol - def register(self, call: Callable) -> None: - """Register a call to handle a message type in the protocol.""" + def handler( + self, call: Callable[[Any, Message], Message] + ) -> Callable[[Any, Message], Message]: + """Decorator for registering calls to handle message types.""" + print('WOULD REGISTER HANDLER TYPE') + return call def handle_raw_message(self, msg: bytes) -> bytes: """Should be called when the receiver gets a message. The return value is the raw response to the message. """ + print('RECEIVER WOULD HANDLE RAW MESSAGE') + del msg # Unused + return b'' async def handle_raw_message_async(self, msg: bytes) -> bytes: """Should be called when the receiver gets a message. The return value is the raw response to the message. """ + raise RuntimeError('Unimplemented!')