From b78081d99996c7430dd4db06a26455ae0005a12c Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 18 Aug 2026 03:53:34 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=BE=D1=87=D1=82=D0=B8=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=87=D0=B8=D0=B9=20=D0=B2=D0=B0=D1=80=D0=B8?= =?UTF-8?q?=D0=B0=D0=BD=D1=82=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mind_reader/app_context.py | 20 -- src/mind_reader/domain/__init__.py | 2 + src/mind_reader/domain/common.py | 4 +- src/mind_reader/domain/context.py | 16 ++ src/mind_reader/domain/session.py | 4 +- src/mind_reader/input/session_input.py | 26 ++- src/mind_reader/network/__init__.py | 10 +- src/mind_reader/network/controller.py | 184 +++++------------- src/mind_reader/network/enums.py | 13 ++ src/mind_reader/network/handlers.py | 27 +++ src/mind_reader/network/message.py | 34 ++++ src/mind_reader/network/protocol.py | 46 ----- .../network/session_network_controller.py | 2 - src/mind_reader/state/session_state.py | 8 + src/mind_reader/ui/views/__init__.py | 4 +- src/mind_reader/ui/views/session_view.py | 16 +- 16 files changed, 189 insertions(+), 227 deletions(-) delete mode 100644 src/mind_reader/app_context.py create mode 100644 src/mind_reader/domain/context.py create mode 100644 src/mind_reader/network/enums.py create mode 100644 src/mind_reader/network/handlers.py create mode 100644 src/mind_reader/network/message.py delete mode 100644 src/mind_reader/network/protocol.py delete mode 100644 src/mind_reader/network/session_network_controller.py diff --git a/src/mind_reader/app_context.py b/src/mind_reader/app_context.py deleted file mode 100644 index cf21e59..0000000 --- a/src/mind_reader/app_context.py +++ /dev/null @@ -1,20 +0,0 @@ -from dataclasses import dataclass -from typing import Optional - -from .input import SessionInput -from .network import SessionNetworkController -from .state import SessionState -from .ui.views import SessionPanel - - -@dataclass(frozen=True) -class SessionContext: - input: SessionInput - network: SessionNetworkController - state: SessionState - view: SessionPanel - - -@dataclass -class AppContext: - session: Optional[SessionContext] = None diff --git a/src/mind_reader/domain/__init__.py b/src/mind_reader/domain/__init__.py index 94c3840..9ea7b6f 100644 --- a/src/mind_reader/domain/__init__.py +++ b/src/mind_reader/domain/__init__.py @@ -1,4 +1,5 @@ from .common import Player, PlayerRole +from .context import SessionContext from .session import ( ArrowDirection, CardData, @@ -11,6 +12,7 @@ from .session import ( __all__ = [ "Player", "PlayerRole", + "SessionContext", "ArrowDirection", "CardData", "CardState", diff --git a/src/mind_reader/domain/common.py b/src/mind_reader/domain/common.py index 6a976f9..dd663ea 100644 --- a/src/mind_reader/domain/common.py +++ b/src/mind_reader/domain/common.py @@ -1,8 +1,8 @@ from dataclasses import dataclass -from enum import Enum, auto +from enum import Enum, StrEnum, auto -class PlayerRole(Enum): +class PlayerRole(StrEnum): SETTER = auto() GUESSER = auto() diff --git a/src/mind_reader/domain/context.py b/src/mind_reader/domain/context.py new file mode 100644 index 0000000..f8afa6c --- /dev/null +++ b/src/mind_reader/domain/context.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..input import SessionInput + from ..network import NetworkController + from ..state import SessionState + from ..ui.views import SessionPanel + + +@dataclass(frozen=True) +class SessionContext: + input: "SessionInput" + network: "NetworkController" + state: "SessionState" + view: "SessionPanel" diff --git a/src/mind_reader/domain/session.py b/src/mind_reader/domain/session.py index 41fb5bd..b3fc553 100644 --- a/src/mind_reader/domain/session.py +++ b/src/mind_reader/domain/session.py @@ -1,9 +1,9 @@ from dataclasses import dataclass -from enum import Enum, auto +from enum import Enum, StrEnum, auto from typing import Optional -class ArrowDirection(Enum): +class ArrowDirection(StrEnum): LEFT = auto() RIGHT = auto() diff --git a/src/mind_reader/input/session_input.py b/src/mind_reader/input/session_input.py index 3f3a425..f640fe2 100644 --- a/src/mind_reader/input/session_input.py +++ b/src/mind_reader/input/session_input.py @@ -1,16 +1,28 @@ -from mind_reader.app_context import SessionContext -from mind_reader.domain import ArrowDirection +from ..domain import ArrowDirection, Player +from ..network import NetworkController, NetworkMessage, NetworkMessageKind class SessionInput: - def __init__(self, context: SessionContext): - self._context = context + def __init__(self, local_player: Player, network: NetworkController): + self._local_player = local_player + self._network = network def start_session(self) -> None: - pass + self._network.send( + NetworkMessage(NetworkMessageKind.SESSION_SESSION_STARTED, {}) + ) - def make_local_move(self, direction: ArrowDirection) -> bool: - pass + def player_move(self, direction: ArrowDirection) -> None: + payload = { + "player": { + "name": self._local_player.name, + "role": self._local_player.role, + }, + "arrow_direction": direction, + } + self._network.send( + NetworkMessage(NetworkMessageKind.SESSION_PLAYER_MOVED, payload) + ) def finish_session(self) -> None: pass diff --git a/src/mind_reader/network/__init__.py b/src/mind_reader/network/__init__.py index 2e53cf3..2bc942b 100644 --- a/src/mind_reader/network/__init__.py +++ b/src/mind_reader/network/__init__.py @@ -1,9 +1,11 @@ from .controller import NetworkController -from .protocol import NetworkMesssage -from .session_network_controller import SessionNetworkController +from .enums import NetworkMessageKind +from .handlers import SessionNetworkMessageHandler +from .message import NetworkMessage __all__ = [ - "SessionNetworkController", "NetworkController", - "NetworkMesssage", + "NetworkMessageKind", + "SessionNetworkMessageHandler", + "NetworkMessage", ] diff --git a/src/mind_reader/network/controller.py b/src/mind_reader/network/controller.py index 4ec8958..cbbf983 100644 --- a/src/mind_reader/network/controller.py +++ b/src/mind_reader/network/controller.py @@ -1,150 +1,58 @@ -import socket -import struct -import threading -from typing import Callable, Optional +from typing import Callable -import wx +import paho.mqtt.client as mqtt +from paho.mqtt.enums import CallbackAPIVersion -from .protocol import NetworkMesssage +from .enums import NetworkMessageKind +from .message import NetworkMessage -OnMessageCallback = Callable[[NetworkMesssage], None] -OnNoticeCallback = Callable[[str], None] +MessageHandler = Callable[[NetworkMessage], None] -def receive_exact(sock: socket.socket, n: int) -> Optional[bytes]: - data = bytearray() +class NetworkController: + def __init__(self, host: str, port: int, username: str, password: str): + self._handlers: set[MessageHandler] = set() - while len(data) < n: - packet = sock.recv(n - len(data)) - if not packet: - return None - data.extend(packet) + self._topic = "mind_reader" + self._client = mqtt.Client(CallbackAPIVersion.VERSION2) + self._client.username_pw_set(username, password) + self._client.on_message = self._on_message + self._client.connect(host, port) + self._client.subscribe("mind_reader/#") - return bytes(data) - - -class NetworkController(threading.Thread): - def __init__( - self, - on_message_callback: OnMessageCallback, - on_notice_callback: Optional[OnNoticeCallback] = None, - ): - super().__init__(daemon=True) - self.on_message_callback = on_message_callback - self.on_notice_callback = on_notice_callback - - self.sock: Optional[socket.socket] = None - self.conn: Optional[socket.socket] = None - - self.is_running: bool = False - self.is_server: bool = False - - def serve(self, host: str = "0.0.0.0", port: int = 8994) -> None: - self.is_server = True - - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self.sock.bind((host, port)) - self.sock.listen(1) - - self.is_running = True - self.start() - - def connect(self, host: str, port: int = 8994) -> bool: - self.is_server = False - - try: - self.conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.conn.connect((host, port)) - - self.is_running = True - self._notify("Connected to host") - self.start() - return True - except Exception as e: - self._notify(f"Connection failed: {e}") - return False - - def send(self, event: str, payload: dict = {}) -> None: - if not self.conn or not self.is_running: - return - - m = NetworkMesssage(event, payload) - - try: - self.conn.sendall(m.encode()) - except Exception as e: - self._notify(f"Send error: {e}") - self._close_active_connection() - - def run(self) -> None: - HEADER_SIZE = 4 - - while self.is_running: - if self.is_server and self.sock and not self.conn: - self._notify("Waiting for peer to connect...") - - try: - self.conn, addr = self.sock.accept() - self._notify(f"Peer connected from {addr[0]}:{addr[1]}") - except Exception: - break - - while self.is_running and self.conn: - try: - header_bytes = receive_exact(self.conn, HEADER_SIZE) - if not header_bytes: - self._notify("Peer disconnected") - break - - body_size = struct.unpack(">I", header_bytes)[0] - - body_bytes = receive_exact(self.conn, body_size) - if not body_bytes: - self._notify("Peer disconnected unexpectedly") - break - - json_str = body_bytes.decode("utf-8") - m = NetworkMesssage.from_json(json_str) - - wx.CallAfter(self.on_message_callback, m) - - except Exception as e: - self._notify(f"Read error: {e}") - break - - self._close_active_connection() - - if not self.is_server: - break - - self.stop() - - def _close_active_connection(self) -> None: - if self.conn: - try: - self.conn.shutdown(socket.SHUT_RDWR) - self.conn.close() - except Exception: - pass - finally: - self.conn = None - - def _notify(self, notice: str) -> None: - if self.on_notice_callback: - wx.CallAfter(self.on_notice_callback, notice) + def start(self) -> None: + self._client.loop_start() def stop(self) -> None: - self.is_running = False + self._client.loop_stop() + self._client.disconnect() - self._close_active_connection() + def add_handler(self, handler: MessageHandler) -> None: + self._handlers.add(handler) - if self.sock: - try: - self.sock.close() - except Exception: - pass - finally: - self.sock = None + def remove_handler(self, handler: MessageHandler) -> None: + self._handlers.remove(handler) - self._notify("Connection closed") + def send(self, network_message: NetworkMessage) -> None: + kind = network_message.kind + payload = network_message.to_json() + + match kind: + case ( + NetworkMessageKind.SESSION_PLAYER_MOVED + | NetworkMessageKind.SESSION_SESSION_FINISHED + | NetworkMessageKind.SESSION_SESSION_STARTED + ): + topic = f"{self._topic}/session" + self._client.publish(topic, payload) + + def _handle(self, network_message: NetworkMessage) -> None: + for handler in self._handlers: + handler(network_message) + + def _on_message( + self, client: mqtt.Client, userdata: None, message: mqtt.MQTTMessage + ) -> None: + json_str = message.payload.decode("utf-8") + network_message = NetworkMessage.from_json(json_str) + self._handle(network_message) diff --git a/src/mind_reader/network/enums.py b/src/mind_reader/network/enums.py new file mode 100644 index 0000000..38756cf --- /dev/null +++ b/src/mind_reader/network/enums.py @@ -0,0 +1,13 @@ +from enum import StrEnum + + +class NetworkMessageKind(StrEnum): + # ----- Lobby ----- + + # ---- Session ---- + SESSION_SESSION_STARTED = "session_started" + SESSION_PLAYER_MOVED = "player_moved" + SESSION_SESSION_FINISHED = "session_finished" + + # ---- System ----- + SYSTEM_INC_MSG_PA_ERR = "incoming_message_parsing_error" diff --git a/src/mind_reader/network/handlers.py b/src/mind_reader/network/handlers.py new file mode 100644 index 0000000..b35795f --- /dev/null +++ b/src/mind_reader/network/handlers.py @@ -0,0 +1,27 @@ +from ..domain import ArrowDirection, Player, PlayerRole +from ..state import SessionState +from .enums import NetworkMessageKind +from .message import NetworkMessage + + +class SessionNetworkMessageHandler: + def __init__(self, state: SessionState): + self._state = state + + def __call__(self, message: NetworkMessage): + match message.kind: + case NetworkMessageKind.SESSION_SESSION_STARTED: + self._state.start_session() + + case NetworkMessageKind.SESSION_PLAYER_MOVED: + player_role = PlayerRole(message.payload["player"]["role"]) + direction = ArrowDirection(message.payload["arrow_direction"]) + + match player_role: + case PlayerRole.SETTER: + self._state.setter_move(direction) + case PlayerRole.GUESSER: + self._state.guesser_move(direction) + + case NetworkMessageKind.SESSION_SESSION_FINISHED: + pass diff --git a/src/mind_reader/network/message.py b/src/mind_reader/network/message.py new file mode 100644 index 0000000..49b9d76 --- /dev/null +++ b/src/mind_reader/network/message.py @@ -0,0 +1,34 @@ +import json +from dataclasses import asdict, dataclass, field +from typing import Any, Optional + +from uuid_extensions import uuid7str + +from .enums import NetworkMessageKind + + +@dataclass(frozen=True) +class NetworkMessage: + kind: NetworkMessageKind + payload: dict[str, Any] + id: str = field(default_factory=uuid7str) + + def to_json(self) -> str: + data = asdict(self) + data["kind"] = self.kind.value + return json.dumps(data) + + @classmethod + def from_json(cls, json_str: str) -> "NetworkMessage": + try: + data = json.loads(json_str) + return cls( + id=data["id"], + kind=NetworkMessageKind(data["kind"]), + payload=data.get("payload", {}), + ) + except (json.JSONDecodeError, KeyError, ValueError) as e: + return cls( + kind=NetworkMessageKind.SYSTEM_INC_MSG_PA_ERR, + payload={"exception": e}, + ) diff --git a/src/mind_reader/network/protocol.py b/src/mind_reader/network/protocol.py deleted file mode 100644 index 7733897..0000000 --- a/src/mind_reader/network/protocol.py +++ /dev/null @@ -1,46 +0,0 @@ -import itertools -import json -import struct -from dataclasses import dataclass, field - -_id_generator = itertools.count(0) - - -@dataclass -class NetworkMesssage: - event: str - payload: dict[str, str] = field(default_factory=dict) - id: int = field(default_factory=lambda: next(_id_generator)) - version: str = "1.0" - - def to_json(self) -> str: - return json.dumps( - { - "version": self.version, - "id": self.id, - "event": self.event, - "payload": self.payload, - }, - ensure_ascii=False, - ) - - @classmethod - def from_json(cls, json_str: str) -> "NetworkMesssage": - data = json.loads(json_str) - return cls( - event=data.get("event", "unknown"), - payload=data.get("payload", {}), - id=data.get("id", -1), - version=data.get("version", "unknown"), - ) - - def encode(self) -> bytes: - raw_bytes = self.to_json().encode("utf-8") - length_prefix = struct.pack(">I", len(raw_bytes)) - return length_prefix + raw_bytes - - -if __name__ == "__main__": - for i in range(10): - message = NetworkMesssage("none") - print(message.encode()) diff --git a/src/mind_reader/network/session_network_controller.py b/src/mind_reader/network/session_network_controller.py deleted file mode 100644 index f87b34a..0000000 --- a/src/mind_reader/network/session_network_controller.py +++ /dev/null @@ -1,2 +0,0 @@ -class SessionNetworkController: - pass diff --git a/src/mind_reader/state/session_state.py b/src/mind_reader/state/session_state.py index c5a9ffe..6f7c48c 100644 --- a/src/mind_reader/state/session_state.py +++ b/src/mind_reader/state/session_state.py @@ -192,3 +192,11 @@ class SessionState: @property def sequence_length(self) -> int: return self._sequence_length + + @property + def local_player(self) -> Player: + return self._local_player + + @property + def remote_player(self) -> Player: + return self._remote_player diff --git a/src/mind_reader/ui/views/__init__.py b/src/mind_reader/ui/views/__init__.py index 8bb7e3d..0740d96 100644 --- a/src/mind_reader/ui/views/__init__.py +++ b/src/mind_reader/ui/views/__init__.py @@ -1,4 +1,4 @@ -from .lobby_view import LobbyPanel +# from .lobby_view import LobbyPanel from .session_view import SessionPanel -__all__ = ["LobbyPanel", "SessionPanel"] +__all__ = ["SessionPanel"] diff --git a/src/mind_reader/ui/views/session_view.py b/src/mind_reader/ui/views/session_view.py index 001fc5c..3784b31 100644 --- a/src/mind_reader/ui/views/session_view.py +++ b/src/mind_reader/ui/views/session_view.py @@ -5,8 +5,9 @@ from typing import Callable, Optional import wx from wx.lib.pubsub import pub -from mind_reader.domain import ArrowDirection, CardState, SessionStats, StepData -from mind_reader.state import SessionState +from ...domain import ArrowDirection, CardState, SessionStats, StepData +from ...input import SessionInput +from ...state import SessionState @dataclass(frozen=True) @@ -112,10 +113,17 @@ class Card(wx.Panel): class SessionPanel(wx.Panel): def __init__( - self, parent, setter_name: str, guesser_name: str, sequence_length: int + self, + parent, + input: SessionInput, + setter_name: str, + guesser_name: str, + sequence_length: int, ): super().__init__(parent) + self._input = input + self._setter_name = setter_name self._guesser_name = guesser_name self._sequence_length = sequence_length @@ -267,7 +275,7 @@ class SessionPanel(wx.Panel): self._handle_arrow_input(ArrowDirection.RIGHT) def _handle_arrow_input(self, direction: ArrowDirection) -> None: - pass + self._input.player_move(direction) def _on_session_started(self, step: StepData) -> None: self.update_setter_card(