Source code for evennia.server.portal.wire_formats.json_standard

"""
JSON MUD Standards wire format (json.mudstandards.org).

This implements the JSON subprotocol from the MUD Standards WebSocket
proposal (https://mudstandards.org/websocket/).

Per the standard:
    - BINARY frames contain regular ANSI in- and output (UTF-8 encoded)
    - TEXT frames contain JSON payloads with the structure:
        {"proto": "<string>", "id": "<string>", "data": "<string>"}

This is the most flexible standard format, supporting GMCP, custom
protocols, and any future structured data through the JSON envelope.
"""

import json

from evennia.server.portal.gmcp_utils import decode_gmcp, encode_gmcp

from .base import WireFormat


[docs] class JsonStandardFormat(WireFormat): """ MUD Standards JSON envelope wire format. Wire format: BINARY frames: Raw ANSI text (UTF-8), used for game text I/O. TEXT frames: JSON envelope {"proto", "id", "data"} for structured/OOB data. Text handling: Outgoing text retains ANSI escape codes (no HTML conversion). Text is sent as BINARY frames. OOB: Supported via TEXT frames. The "proto" field identifies the protocol (e.g., "gmcp"), "id" identifies the command, and "data" carries the JSON payload. """ name = "json.mudstandards.org" supports_oob = True
[docs] def decode_incoming(self, payload, is_binary, protocol_flags=None): """ Decode incoming WebSocket message. BINARY frames are treated as raw text input. TEXT frames are parsed as JSON envelopes. Args: payload (bytes): The raw frame payload. is_binary (bool): True for BINARY frames, False for TEXT. protocol_flags (dict, optional): Not used. Returns: dict or None: kwargs for data_in(). """ if is_binary: # BINARY frame = raw text input try: text = payload.decode("utf-8").strip() except UnicodeDecodeError: return None if not text: return None return {"text": [[text], {}]} else: # TEXT frame = JSON envelope try: envelope = json.loads(payload.decode("utf-8")) except (json.JSONDecodeError, UnicodeDecodeError): return None if not isinstance(envelope, dict): return None proto = envelope.get("proto", "") cmd_id = envelope.get("id", "") data = envelope.get("data", "") # Validate envelope field types — malformed envelopes are dropped if not isinstance(proto, str): return None if not isinstance(cmd_id, str): cmd_id = str(cmd_id) if cmd_id is not None else "" if not isinstance(data, str): try: data = json.dumps(data) except (TypeError, ValueError): data = str(data) return self._decode_envelope(proto, cmd_id, data)
def _decode_envelope(self, proto, cmd_id, data): """ Decode a JSON envelope into Evennia inputfunc kwargs. Args: proto (str): The protocol identifier (e.g., "gmcp", "text"). cmd_id (str): The command identifier (e.g., GMCP package name). data (str): The payload string. Returns: dict or None: kwargs for data_in(). """ if proto == "gmcp": # GMCP: id is the package name, data is the JSON payload cmd_id = cmd_id.strip() if not cmd_id: return None gmcp_string = "%s %s" % (cmd_id, data) if data else cmd_id return decode_gmcp(gmcp_string) elif proto == "text": # Text input sent via JSON envelope if not data: return None return {"text": [[data], {}]} elif proto == "websocket_close": return {"websocket_close": [[], {}]} else: # Generic protocol — pass through as-is. # Prefer cmd_id as the inputfunc name, fall back to proto. try: parsed_data = json.loads(data) if data else {} except (json.JSONDecodeError, ValueError): parsed_data = data args = [] kwargs = {} if isinstance(parsed_data, dict): kwargs = parsed_data elif isinstance(parsed_data, list): args = parsed_data else: args = [parsed_data] funcname = cmd_id if cmd_id else proto if not funcname: return None return {funcname: [args, kwargs]}
[docs] def encode_prompt(self, *args, protocol_flags=None, **kwargs): """ Encode a prompt. For the JSON standard format, prompts are sent as a JSON envelope in a TEXT frame with proto="prompt", allowing the client to distinguish prompts from regular text. Returns: tuple or None: (json_bytes, False) for TEXT frame. """ extracted = self._extract_text_and_flags(args, kwargs, protocol_flags) if extracted is None: return None text, raw, nocolor, screenreader = extracted text = self._process_ansi(text, raw, nocolor, screenreader) envelope = { "proto": "prompt", "id": "", "data": text, } return (json.dumps(envelope).encode("utf-8"), False)
[docs] def encode_default(self, cmdname, *args, protocol_flags=None, **kwargs): """ Encode an OOB command as a GMCP-in-JSON envelope. OOB commands are sent as TEXT frames with the JSON envelope format. The command is translated to GMCP naming conventions and wrapped in a {"proto": "gmcp", "id": "Package.Name", "data": "..."} envelope. Args: cmdname (str): The OOB command name. *args: Command arguments. protocol_flags (dict, optional): Not used. **kwargs: Command keyword arguments. Returns: tuple or None: (json_bytes, False) for TEXT frame, or None if cmdname is "options". """ if cmdname == "options": return None kwargs.pop("options", None) # Encode as GMCP string first, then wrap in JSON envelope gmcp_string = encode_gmcp(cmdname, *args, **kwargs) # Split the GMCP string into package name and payload parts = gmcp_string.split(None, 1) gmcp_package = parts[0] gmcp_data = parts[1] if len(parts) > 1 else "" envelope = { "proto": "gmcp", "id": gmcp_package, "data": gmcp_data, } return (json.dumps(envelope).encode("utf-8"), False)