Client

examples/client.py
import enum
import io
import uuid
from typing import Any, Callable, Coroutine

import anyio

from cubes import net
from cubes.net import serializers

_PROTOCOL = 766
_HOST = "127.0.0.1"
_PORT = 25565
_PLAYER_NAME = "IamSmesharik"


class NotConnectedError(Exception):
    pass


class AlreadyConnectedError(Exception):
    pass


class UnsuitedConnectionStatusForOperationError(Exception):
    pass


class DisconnectedByServerError(Exception):
    pass


class InvalidPlayerNameFromServer(Exception):
    pass


class UnexpectedPacketError(Exception):
    pass


class ConnectionState(enum.IntEnum):
    HANDSHAKE = 0
    STATUS = 1
    LOGIN = 2
    TRANSFER = 3
    CONFIGURATION = 4
    PLAY = 5


class Client:
    _conn: net.Connection | None
    _state: ConnectionState | None

    def __init__(self, host: str, port: int) -> None:
        self._host, self._port = host, port
        self._conn = None
        self._state = None

    @property
    def connection(self) -> net.Connection:
        if self._conn is None:
            raise NotConnectedError
        return self._conn

    @property
    def state(self) -> ConnectionState:
        if self._state is None:
            raise NotConnectedError
        return self._state

    async def connect(self) -> None:
        if self._conn is not None:
            raise AlreadyConnectedError
        stream = await anyio.connect_tcp(self._host, self._port)
        self._conn = net.Connection(stream)
        self._state = ConnectionState.HANDSHAKE

    async def disconnect(self) -> None:
        if self._conn:
            await self._conn.close()
        self._state = None

    async def login(self, player_name: str) -> uuid.UUID:
        if self._state != ConnectionState.HANDSHAKE:
            raise UnsuitedConnectionStatusForOperationError(self._state)

        handshake = io.BytesIO()
        serializers.VarIntSerializer(0x00).to_buffer(handshake)
        serializers.VarIntSerializer(_PROTOCOL).to_buffer(handshake)
        serializers.StringSerializer(self.connection.remote_address[0])
        serializers.UnsignedShortSerializer(self.connection.remote_address[1])
        serializers.VarIntSerializer(ConnectionState.LOGIN)

        login_start = io.BytesIO()
        serializers.VarIntSerializer(0x00).to_buffer(login_start)
        serializers.StringSerializer(player_name).to_buffer(login_start)

        await self.connection.send(handshake, login_start)

        self._state = ConnectionState.LOGIN

        response = await self.connection.receive()
        packet_id = serializers.VarIntSerializer.from_buffer(response)
        match packet_id:
            case 0x00:
                raise DisconnectedByServerError(
                    serializers.StringSerializer.from_buffer(response)
                )
            case 0x02:
                uuid_ = serializers.UUIDSerializer.from_buffer(response)
                player_name_from_server = serializers.StringSerializer.from_buffer(
                    response
                )
                if player_name != player_name_from_server:
                    raise InvalidPlayerNameFromServer(player_name_from_server)
                self._state = ConnectionState.CONFIGURATION
                return uuid_
            case _:
                raise UnexpectedPacketError(hex(packet_id))

    async def run(
        self,
        handler: Callable[[net.Connection, io.BytesIO], Coroutine[Any, Any, None]],
        packet_receive_timeout: float = 20,
    ) -> None:
        while True:
            with anyio.fail_after(packet_receive_timeout):
                packet = await self.connection.receive()
            await handler(self.connection, packet)

    async def __aenter__(self) -> "Client":
        await self.connect()
        return self

    async def __aexit__(self):
        await self.disconnect()


async def process_packet(conn: net.Connection, packet: io.BytesIO): ...


async def main():
    async with Client(_HOST, _PORT) as client:
        await client.login(_PLAYER_NAME)
        await client.run(process_packet)