19 Commits
Author SHA1 Message Date
vladimir 4533789432 feat: переработка NetwrokMessage и EventMapper 2026-08-25 01:50:45 +03:00
vladimir b1132715e1 feat: наследование от BaseEvent и метод from_payload для каждого из классов Event 2026-08-25 01:49:12 +03:00
vladimir 878b83dc1c feat: метод from_dict для класса Player 2026-08-25 01:48:02 +03:00
vladimir 28030412d0 feat: базовый класс BaseEvent с универсальным методом to_payload для гарантированного преобразования всех данных в строки 2026-08-25 01:46:58 +03:00
vladimir 6017c2d757 fix: переработка методов NetworkController 2026-08-24 01:43:03 +03:00
vladimir 53be71316c fix: удаление ненужных методов 2026-08-23 21:06:02 +03:00
vladimir 473b03c1bb fix: корректное определение текущего индекса в методах возврата состояния карт 2026-08-23 21:06:02 +03:00
vladimir a07d94bcc4 fix: корректное определение текущего индекса 2026-08-23 21:05:51 +03:00
vladimir 9a4c98fb76 wip: без комментариев 2026-08-23 01:49:02 +03:00
vladimir 5c7966755f wip: возможность получения загаданной стрелки только после попытки отгадки 2026-08-19 04:06:30 +03:00
vladimir 0730d8180b fix: исправлен порядок открытия карт у загадывающего 2026-08-19 01:39:39 +03:00
vladimir b78081d999 feat: почти рабочий вариант session 2026-08-18 03:53:34 +03:00
vladimir a7fcf3dda8 refactor: разделение domain.py на модуль domain, включающий common.py и session.py 2026-08-17 02:03:46 +03:00
vladimir ab6fd43883 feat: начало работы на AppContext 2026-08-14 03:17:30 +03:00
vladimir dc25e2d98b feat: начало работы над SessionNetworkController 2026-08-14 03:17:08 +03:00
vladimir ac7a841d05 feat: начало работы над SessionInput 2026-08-14 03:16:24 +03:00
vladimir bc5ae5394c chore: добавление директории sandbox в .gitignore 2026-08-14 02:43:59 +03:00
vladimir e3f5828334 refactor: разделение кода в session_view.py 2026-08-14 02:42:18 +03:00
vladimir c657415bf0 feat: правильная работа SessionState 2026-08-13 03:41:03 +03:00
23 changed files with 1078 additions and 454 deletions
+4 -1
View File
@@ -38,4 +38,7 @@ env/
venv/
ENV/
env.bak/
venv.bak/
venv.bak/
# Sandbox
sandbox
+36
View File
@@ -0,0 +1,36 @@
from .common import Player, PlayerRole
from .context import SessionContext
from .session import (
ArrowDirection,
CardData,
CardState,
GuesserMoveEvent,
NextStepRequestedEvent,
PlayerHelloEvent,
RevealSecretEvent,
SessionEvent,
SessionPhase,
SessionStats,
SetterMoveEvent,
StepData,
StepState,
)
__all__ = [
"Player",
"PlayerRole",
"SessionContext",
"ArrowDirection",
"CardData",
"CardState",
"GuesserMoveEvent",
"NextStepRequestedEvent",
"PlayerHelloEvent",
"RevealSecretEvent",
"SessionEvent",
"SessionPhase",
"SessionStats",
"SetterMoveEvent",
"StepData",
"StepState",
]
+13
View File
@@ -0,0 +1,13 @@
from dataclasses import dataclass
from enum import Enum, StrEnum, auto
class PlayerRole(StrEnum):
SETTER = auto()
GUESSER = auto()
@dataclass(frozen=True)
class Player:
name: str
role: PlayerRole
+16
View File
@@ -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"
+105
View File
@@ -0,0 +1,105 @@
from dataclasses import dataclass
from enum import Enum, StrEnum, auto
from typing import Optional
from .common import Player
class ArrowDirection(StrEnum):
LEFT = auto()
RIGHT = auto()
HIDDEN = auto()
class StepState(Enum):
WAITING_FOR_SETTER = auto()
WAITING_FOR_GUESSER = auto()
WAITING_FOR_CLOSURE = auto()
SESSION_FINISHED = auto()
class SessionPhase(Enum):
INITIALIZING = auto()
WAITING_FOR_SETTER = auto()
WAITING_FOR_GUESSER = auto()
WAITING_FOR_REVEAL = auto()
CHECKOUT = auto()
FINISHED = auto()
class CardState(Enum):
SETTER_NOT_SET = auto()
SETTER_WAITING = auto()
SETTER_HIDDEN = auto()
SETTER_REVEALED = auto()
GUESSER_DISABLED = auto()
GUESSER_WAITING = auto()
GUESSER_CORRECT = auto()
GUESSER_WRONG = auto()
@dataclass(frozen=True)
class CardData:
direction: Optional[ArrowDirection]
state: CardState
@dataclass(frozen=True)
class StepData:
index: int
setter: CardData
guesser: CardData
@dataclass
class SessionStats:
total: int
setted: int
correct: int
wrong: int
taken_time: float
@property
def accuracy(self) -> float:
accuracy: float
if self.total == 0:
accuracy = 0.0
accuracy = (self.correct / self.total) * 100
return round(accuracy, 1)
@dataclass(frozen=True)
class PlayerHelloEvent:
player: Player
@dataclass(frozen=True)
class SetterMoveEvent:
player: Player
direction: ArrowDirection
@dataclass(frozen=True)
class GuesserMoveEvent:
player: Player
direction: ArrowDirection
@dataclass(frozen=True)
class RevealSecretEvent:
player: Player
direction: ArrowDirection
@dataclass(frozen=True)
class NextStepRequestedEvent:
player: Player
SessionEvent = (
PlayerHelloEvent
| SetterMoveEvent
| GuesserMoveEvent
| RevealSecretEvent
| NextStepRequestedEvent
)
+3
View File
@@ -0,0 +1,3 @@
from .session_input import SessionInput
__all__ = ["SessionInput"]
+70
View File
@@ -0,0 +1,70 @@
from ..domain import ArrowDirection, Player, PlayerRole
from ..network import NetworkController, NetworkMessage, NetworkMessageKind
class SessionInput:
def __init__(self, local_player: Player, network: NetworkController):
self._local_player = local_player
self._network = network
def start_session(self) -> None:
self._network.send(
NetworkMessage(NetworkMessageKind.SESSION_SESSION_STARTED, {})
)
def player_move(self, direction: ArrowDirection) -> None:
match self._local_player.role:
case PlayerRole.SETTER:
local_message = NetworkMessage(
NetworkMessageKind.SESSION_PLAYER_MOVED,
{
"player": {
"name": self._local_player.name,
"role": self._local_player.role,
},
"arrow_direction": direction,
},
)
network_message = NetworkMessage(
NetworkMessageKind.SESSION_PLAYER_MOVED,
{
"player": {
"name": self._local_player.name,
"role": self._local_player.role,
},
"arrow_direction": ArrowDirection.HIDDEN,
},
local_message.id,
)
self._network.send_with_looback(local_message, network_message)
case PlayerRole.GUESSER:
self._network.send(
NetworkMessage(
NetworkMessageKind.SESSION_PLAYER_MOVED,
{
"player": {
"name": self._local_player.name,
"role": self._local_player.role,
},
"arrow_direction": direction,
},
)
)
self._network.send(
NetworkMessage(
NetworkMessageKind.SESSION_SECRET_REVEAL_REQUESTED,
{
"player": {
"name": self._local_player.name,
"role": self._local_player.role,
},
},
)
)
def finish_session(self) -> None:
pass
+10 -6
View File
@@ -1,7 +1,11 @@
from .controller import NetworkController
from .protocol import NetworkMesssage
# from .controller import NetworkController
# from .enums import NetworkMessageKind
# from .handlers import SessionNetworkMessageHandler
# from .message import NetworkMessage
__all__ = [
"NetworkController",
"NetworkMesssage",
]
# __all__ = [
# "NetworkController",
# "NetworkMessageKind",
# "SessionNetworkMessageHandler",
# "NetworkMessage",
# ]
+90 -130
View File
@@ -1,150 +1,110 @@
import socket
import struct
import threading
from typing import Callable, Optional
import logging
from typing import Optional
import wx
import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion
from .protocol import NetworkMesssage
from ..state_machines.session.events import SessionEvent
from ..state_machines.session.machine import SessionStateMachine
from .message import EventMapper, NetworkMessage, SessionMessageKind
OnMessageCallback = Callable[[NetworkMesssage], None]
OnNoticeCallback = Callable[[str], None]
logger = logging.getLogger(__name__)
def receive_exact(sock: socket.socket, n: int) -> Optional[bytes]:
data = bytearray()
while len(data) < n:
packet = sock.recv(n - len(data))
if not packet:
return None
data.extend(packet)
return bytes(data)
class NetworkController(threading.Thread):
class NetworkController:
def __init__(
self,
on_message_callback: OnMessageCallback,
on_notice_callback: Optional[OnNoticeCallback] = None,
host: str,
port: int,
username: str,
password: str,
lobby,
session: SessionStateMachine,
):
super().__init__(daemon=True)
self.on_message_callback = on_message_callback
self.on_notice_callback = on_notice_callback
self._lobby_topic = "mind_reader/lobby"
self._session_topic = "mind_reader/session"
self.sock: Optional[socket.socket] = None
self.conn: Optional[socket.socket] = None
self._lobby = lobby
self._session = session
self.is_running: bool = False
self.is_server: bool = False
self._pending_loopbacks: dict[str, NetworkMessage] = {}
def serve(self, host: str = "0.0.0.0", port: int = 8994) -> None:
self.is_server = True
self._client = mqtt.Client(CallbackAPIVersion.VERSION2)
self._client.username_pw_set(username, password)
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._client.message_callback_add(
self._lobby_topic, self._on_lobby_message
)
self._client.message_callback_add(
self._session_topic, self._on_session_message
)
self.is_running = True
self.start()
self._client.connect(host, port)
def connect(self, host: str, port: int = 8994) -> bool:
self.is_server = False
self._client.subscribe(self._lobby_topic)
self._client.subscribe(self._session_topic)
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 send(self, network_message: NetworkMessage) -> None:
match network_message.kind:
case SessionMessageKind():
self._client.publish(
self._session_topic, network_message.to_json()
)
if self.sock:
try:
self.sock.close()
except Exception:
pass
finally:
self.sock = None
def send_with_loopback(
self,
local_message: NetworkMessage,
network_message: NetworkMessage,
) -> None:
if local_message.id != network_message.id:
raise ValueError(
f"Mismatched message IDs in send_with_loopback: "
f"local_message.id='{local_message.id}'"
f"vs network_message.id='{network_message.id}'"
)
self._notify("Connection closed")
self._pending_loopbacks[local_message.id] = local_message
self.send(network_message)
def _pop_loopback(self, message_id: str) -> Optional[NetworkMessage]:
return self._pending_loopbacks.pop(message_id, None)
def _parse_incoming_message(
self, message: mqtt.MQTTMessage
) -> Optional[NetworkMessage]:
try:
json_str = message.payload.decode()
network_message = NetworkMessage.from_json(json_str)
except Exception as err:
logger.error("Failed to parse incoming MQTT message: %s", err)
return None
loopback_message = self._pop_loopback(network_message.id)
return loopback_message or network_message
def _on_lobby_message(
self, client: mqtt.Client, userdata: None, message: mqtt.MQTTMessage
) -> None:
network_message = self._parse_incoming_message(message)
if network_message is None:
return
event = EventMapper.from_message(network_message)
# if isinstance(event, LobbyEvent):
# self._lobby.process(event)
def _on_session_message(
self, client: mqtt.Client, userdata: None, message: mqtt.MQTTMessage
) -> None:
network_message = self._parse_incoming_message(message)
if network_message is None:
return
event = EventMapper.from_message(network_message)
if isinstance(event, SessionEvent):
self._session.process(event)
+108
View File
@@ -0,0 +1,108 @@
import json
import logging
from dataclasses import asdict, dataclass, field
from enum import StrEnum, auto
from typing import Any, Optional
from uuid_extensions import uuid7str
from ..state_machines.lobby.domain import Player, PlayerRole
from ..state_machines.session.domain import ArrowDirection
from ..state_machines.session.events import (
GuesserMoveEvent,
NextStepRequestedEvent,
PlayerHelloEvent,
RevealSecretEvent,
SessionEvent,
SetterMoveEvent,
)
logger = logging.getLogger(__name__)
class SessionMessageKind(StrEnum):
PLAYER_HELLO = auto()
SETTER_MOVED = auto()
GUESSER_MOVED = auto()
SECRET_REVEALED = auto()
NEXT_STEP_REQUESTED = auto()
NetworkMessageKind = SessionMessageKind
Event = SessionEvent
EVENT_TO_KIND_MAP: dict[type[Event], NetworkMessageKind] = {
PlayerHelloEvent: SessionMessageKind.PLAYER_HELLO,
SetterMoveEvent: SessionMessageKind.SETTER_MOVED,
GuesserMoveEvent: SessionMessageKind.GUESSER_MOVED,
RevealSecretEvent: SessionMessageKind.SECRET_REVEALED,
NextStepRequestedEvent: SessionMessageKind.NEXT_STEP_REQUESTED,
}
KIND_TO_EVENT_MAP = {
kind: event_cls for event_cls, kind in EVENT_TO_KIND_MAP.items()
}
def serializer(obj: Any) -> Any:
if isinstance(obj, StrEnum):
return obj.value
raise TypeError(
f"Object of type {type(obj).__name__} is not JSON serializable"
)
@dataclass(frozen=True)
class NetworkMessage:
kind: NetworkMessageKind
payload: dict[str, Any] = field(default_factory=dict)
id: str = field(default_factory=uuid7str)
@classmethod
def from_json(cls, json_str: str) -> "NetworkMessage":
try:
data = json.loads(json_str)
return cls(
kind=NetworkMessageKind(data["kind"]),
payload=data.get("payload", {}),
id=data["id"],
)
except (KeyError, ValueError, json.JSONDecodeError) as err:
logger.error(
"Failed to deserialize NetworkMessage from JSON: %s", err
)
raise
def to_json(self) -> str:
data = {"kind": self.kind.value, "payload": self.payload, "id": self.id}
return json.dumps(data, ensure_ascii=False, default=serializer)
class EventMapper:
@staticmethod
def to_message(event: Event) -> NetworkMessage:
event_type = type(event)
kind = EVENT_TO_KIND_MAP.get(event_type)
if not kind:
logger.error(
"Attempted to serialize unregistered event: %s", event_type
)
raise ValueError(
f"Event {event_type} is not registered in EVENT_TO_KIND_MAP"
)
return NetworkMessage(kind=kind, payload=event.to_payload())
@staticmethod
def from_message(message: NetworkMessage) -> Event | None:
event_cls = KIND_TO_EVENT_MAP.get(message.kind)
if not event_cls:
return None
try:
return event_cls.from_payload(message.payload)
except (KeyError, ValueError, TypeError):
return None
-46
View File
@@ -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())
+3
View File
@@ -0,0 +1,3 @@
from .session_state import SessionState
__all__ = ["SessionState"]
+219
View File
@@ -0,0 +1,219 @@
from wx.lib.pubsub import pub
from mind_reader.domain import (
ArrowDirection,
CardData,
CardState,
Player,
PlayerRole,
StepData,
StepState,
)
class SessionState:
def __init__(
self,
local_player: Player,
remote_player: Player,
sequence_length: int = 20,
):
self._local_player = local_player
self._remote_player = remote_player
self._sequence_length = sequence_length
self._setter_moves: list[ArrowDirection] = []
self._guesser_moves: list[ArrowDirection] = []
self._step_state: StepState = StepState.WAITING_FOR_SETTER
@property
def _completed_move_count(self):
return len(self._guesser_moves)
def _get_setter_card_state(self, index: int) -> CardState:
completed = self._completed_move_count
if index > completed:
return CardState.SETTER_NOT_SET
elif index == completed:
if self._local_player.role == PlayerRole.SETTER:
return CardState.SETTER_REVEALED
else:
return CardState.SETTER_HIDDEN
return CardState.SETTER_REVEALED
def _get_guesser_card_state(self, index: int) -> CardState:
completed = self._completed_move_count
if index > completed:
return CardState.GUESSER_DISABLED
elif index == completed:
if self._step_state == StepState.WAITING_FOR_GUESSER:
return CardState.GUESSER_WAITING
else:
return CardState.GUESSER_DISABLED
else:
setter_move = self._setter_moves[index]
guesser_move = self._guesser_moves[index]
if setter_move == guesser_move:
return CardState.GUESSER_CORRECT
else:
return CardState.GUESSER_WRONG
def start_session(self) -> None:
index = 0
pub.sendMessage(
"session.session_started",
step=StepData(
index=index,
setter=CardData(
direction=None,
state=self._get_setter_card_state(index),
),
guesser=CardData(
direction=None,
state=self._get_guesser_card_state(index),
),
),
)
def setter_move(self, direction: ArrowDirection) -> bool:
if self._step_state != StepState.WAITING_FOR_SETTER:
return False
self._setter_moves.append(direction)
self._step_state = StepState.WAITING_FOR_GUESSER
index = len(self._setter_moves) - 1
pub.sendMessage(
"session.setter_moved",
step=StepData(
index=index,
setter=CardData(
direction=direction,
state=self._get_setter_card_state(index),
),
guesser=CardData(
direction=direction,
state=self._get_guesser_card_state(index),
),
),
)
return True
def guesser_move(self, direction: ArrowDirection) -> bool:
if self._step_state != StepState.WAITING_FOR_GUESSER:
return False
self._guesser_moves.append(direction)
self._step_state = StepState.WAITING_FOR_CLOSURE
return True
def step_close(self, direction: ArrowDirection) -> bool:
if self._step_state != StepState.WAITING_FOR_CLOSURE:
return False
if self._completed_move_count < self._sequence_length:
self._step_state = StepState.WAITING_FOR_SETTER
else:
self._step_state = StepState.SESSION_FINISHED
if self._local_player.role == PlayerRole.GUESSER:
self._setter_moves[-1] = direction
index = len(self._guesser_moves) - 1
pub.sendMessage(
"session.guesser_moved",
step=StepData(
index=index,
setter=CardData(
direction=self._setter_moves[index],
state=self._get_setter_card_state(index),
),
guesser=CardData(
direction=self._guesser_moves[index],
state=self._get_guesser_card_state(index),
),
),
)
next = index + 1
pub.sendMessage(
"session.step_closed",
step=StepData(
index=next,
setter=CardData(
direction=None,
state=self._get_setter_card_state(next),
),
guesser=CardData(
direction=None,
state=self._get_guesser_card_state(next),
),
),
)
if self._step_state == StepState.SESSION_FINISHED:
pub.sendMessage("session.session_finished")
return True
def reveal_setter_move(self) -> ArrowDirection:
if self._local_player.role == PlayerRole.SETTER:
if self._step_state == StepState.WAITING_FOR_CLOSURE:
return self._setter_moves[-1]
return ArrowDirection.HIDDEN
def local_move(self, direction: ArrowDirection) -> bool:
if not self.can_local_move():
return False
if self._local_player.role == PlayerRole.SETTER:
return self.setter_move(direction)
else:
return self.guesser_move(direction)
def can_local_move(self) -> bool:
match self._step_state:
case StepState.WAITING_FOR_SETTER:
return self._local_player.role == PlayerRole.SETTER
case StepState.WAITING_FOR_GUESSER:
return self._local_player.role == PlayerRole.GUESSER
case StepState.WAITING_FOR_CLOSURE:
return False
case StepState.SESSION_FINISHED:
return False
@property
def setter_name(self) -> str:
if self._local_player.role == PlayerRole.SETTER:
return self._local_player.name
else:
return self._remote_player.name
@property
def guesser_name(self) -> str:
if self._local_player.role == PlayerRole.GUESSER:
return self._local_player.name
else:
return self._remote_player.name
@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
@@ -0,0 +1,22 @@
from dataclasses import asdict, dataclass, is_dataclass
from enum import StrEnum
from typing import Any
def _factory(d: list[tuple[str, Any]]) -> dict[str, Any]:
def _convert(obj: Any) -> Any:
if isinstance(obj, StrEnum):
return obj.value
if isinstance(obj, list | tuple):
return [_convert(item) for item in obj]
if isinstance(obj, dict):
return {k: _convert(v) for k, v in obj.items()}
return obj
return {k: _convert(v) for k, v in d}
@dataclass(frozen=True)
class BaseEvent:
def to_payload(self) -> dict[str, Any]:
return asdict(self, dict_factory=_factory)
@@ -0,0 +1,19 @@
from dataclasses import dataclass
from enum import StrEnum, auto
class PlayerRole(StrEnum):
SETTER = auto()
GUESSER = auto()
@dataclass(frozen=True)
class Player:
name: str
role: PlayerRole
@classmethod
def from_dict(cls, d: dict) -> "Player":
return cls(
name=d["player"]["name"], role=PlayerRole(d["player"]["role"])
)
@@ -0,0 +1,27 @@
from enum import Enum, StrEnum, auto
class ArrowDirection(StrEnum):
LEFT = auto()
RIGHT = auto()
HIDDEN = auto()
class SessionPhase(Enum):
INITIALIZING = auto()
WAITING_FOR_SETTER = auto()
WAITING_FOR_GUESSER = auto()
WAITING_FOR_REVEAL = auto()
CHECKOUT = auto()
FINISHED = auto()
class CardState(Enum):
SETTER_NOT_SET = auto()
SETTER_WAITING = auto()
SETTER_HIDDEN = auto()
SETTER_REVEALED = auto()
GUESSER_DISABLED = auto()
GUESSER_WAITING = auto()
GUESSER_CORRECT = auto()
GUESSER_WRONG = auto()
@@ -0,0 +1,71 @@
from dataclasses import dataclass
from ..base_event import BaseEvent
from ..lobby.domain import Player
from ..session.domain import ArrowDirection
@dataclass(frozen=True)
class PlayerHelloEvent(BaseEvent):
player: Player
@classmethod
def from_payload(cls, p: dict) -> "PlayerHelloEvent":
return cls(player=Player.from_dict(p))
@dataclass(frozen=True)
class SetterMoveEvent(BaseEvent):
player: Player
direction: ArrowDirection
@classmethod
def from_payload(cls, p: dict) -> "SetterMoveEvent":
return cls(
player=Player.from_dict(p),
direction=ArrowDirection(p["arrow_direction"]),
)
@dataclass(frozen=True)
class GuesserMoveEvent(BaseEvent):
player: Player
direction: ArrowDirection
@classmethod
def from_payload(cls, p: dict) -> "GuesserMoveEvent":
return cls(
player=Player.from_dict(p),
direction=ArrowDirection(p["arrow_direction"]),
)
@dataclass(frozen=True)
class RevealSecretEvent(BaseEvent):
player: Player
direction: ArrowDirection
@classmethod
def from_payload(cls, p: dict) -> "RevealSecretEvent":
return cls(
player=Player.from_dict(p),
direction=ArrowDirection(p["arrow_direction"]),
)
@dataclass(frozen=True)
class NextStepRequestedEvent(BaseEvent):
player: Player
@classmethod
def from_payload(cls, p: dict) -> "NextStepRequestedEvent":
return cls(player=Player.from_dict(p))
SessionEvent = (
PlayerHelloEvent
| SetterMoveEvent
| GuesserMoveEvent
| RevealSecretEvent
| NextStepRequestedEvent
)
@@ -0,0 +1,174 @@
from ..lobby.domain import Player, PlayerRole
from ..session.domain import ArrowDirection, CardState, SessionPhase
from ..session.events import (
GuesserMoveEvent,
NextStepRequestedEvent,
PlayerHelloEvent,
RevealSecretEvent,
SessionEvent,
SetterMoveEvent,
)
class SessionStateMachine:
def __init__(
self, setter: Player, guessers: set[Player], sequence_length: int = 20
):
self._setter = setter
self._guessers = guessers
self._pending_players: set[Player] = set(guessers)
self._pending_players.add(setter)
self._sequence_length = sequence_length
self._setter_moves: list[ArrowDirection] = []
self._guessers_moves: dict[Player, list[ArrowDirection]] = {
player: [] for player in guessers
}
self._phase: SessionPhase = SessionPhase.INITIALIZING
def process(self, event: SessionEvent) -> bool:
match (self._phase, event):
case (SessionPhase.INITIALIZING, PlayerHelloEvent(player)):
self._pending_players.remove(player)
if not self._pending_players:
self._phase = SessionPhase.WAITING_FOR_SETTER
self._pending_players = {self._setter}
return True
case (
SessionPhase.WAITING_FOR_SETTER,
SetterMoveEvent(player, direction),
):
self._pending_players.remove(player)
if not self._pending_players:
self._setter_moves.append(direction)
self._phase = SessionPhase.WAITING_FOR_GUESSER
self._pending_players = set(self._guessers)
return True
case (
SessionPhase.WAITING_FOR_GUESSER,
GuesserMoveEvent(player, direction),
):
self._guessers_moves[player].append(direction)
self._pending_players.remove(player)
if not self._pending_players:
self._phase = SessionPhase.WAITING_FOR_REVEAL
self._pending_players = {self._setter}
return True
case (
SessionPhase.WAITING_FOR_REVEAL,
RevealSecretEvent(player, direction),
):
self._pending_players.remove(player)
if not self._pending_players:
self._phase = SessionPhase.CHECKOUT
setter_move_index = len(self._setter_moves) - 1
self._setter_moves[setter_move_index] = direction
self._pending_players = set(self._guessers)
return True
case (SessionPhase.CHECKOUT, NextStepRequestedEvent(player)):
self._pending_players.remove(player)
if not self._pending_players:
if len(self._setter_moves) >= self._sequence_length:
self._phase = SessionPhase.FINISHED
self._pending_players.clear()
else:
self._phase = SessionPhase.WAITING_FOR_SETTER
self._pending_players = {self._setter}
return True
case _:
return False
def get_card_state(self, player: Player, index: int) -> CardState:
match player.role:
case PlayerRole.SETTER:
return self._get_setter_card_state(index)
case PlayerRole.GUESSER:
return self._get_guesser_card_state(player, index)
def _get_setter_card_state(self, index: int) -> CardState:
current_index = self.current_index
if index > current_index:
return CardState.SETTER_NOT_SET
elif index == current_index:
match self._phase:
case SessionPhase.INITIALIZING:
return CardState.SETTER_NOT_SET
case SessionPhase.WAITING_FOR_SETTER:
return CardState.SETTER_WAITING
case (
SessionPhase.WAITING_FOR_GUESSER
| SessionPhase.WAITING_FOR_REVEAL
):
if self._setter_moves[-1] == ArrowDirection.HIDDEN:
return CardState.SETTER_HIDDEN
else:
return CardState.SETTER_REVEALED
case SessionPhase.CHECKOUT | SessionPhase.FINISHED:
return CardState.SETTER_REVEALED
else:
return CardState.SETTER_REVEALED
def _get_guesser_card_state(self, player: Player, index: int) -> CardState:
def _check_direction(
self: SessionStateMachine, player: Player, index: int
) -> CardState:
if self._setter_moves[index] == self._guessers_moves[player][index]:
return CardState.GUESSER_CORRECT
else:
return CardState.GUESSER_WRONG
current_index = self.current_index
if index > current_index:
return CardState.GUESSER_DISABLED
elif index == current_index:
match self._phase:
case (
SessionPhase.INITIALIZING | SessionPhase.WAITING_FOR_SETTER
):
return CardState.GUESSER_DISABLED
case (
SessionPhase.WAITING_FOR_GUESSER
| SessionPhase.WAITING_FOR_REVEAL
):
return CardState.GUESSER_WAITING
case SessionPhase.CHECKOUT | SessionPhase.FINISHED:
return _check_direction(self, player, index)
else:
return _check_direction(self, player, index)
def get_setter_move(self, index: int) -> ArrowDirection | None:
try:
return self._setter_moves[index]
except IndexError:
return None
def get_guesser_move(
self, player: Player, index: int
) -> ArrowDirection | None:
try:
return self._guessers_moves[player][index]
except IndexError:
return None
@property
def current_index(self) -> int:
setter_step_number = len(self._setter_moves)
if setter_step_number:
if self._phase == SessionPhase.WAITING_FOR_SETTER:
return setter_step_number
else:
return setter_step_number - 1
return 0
+3 -4
View File
@@ -1,5 +1,4 @@
from mind_reader.ui.views.lobby_view import LobbyPanel
# from .lobby_view import LobbyPanel
from .session_view import SessionPanel
__all__ = [
"LobbyPanel",
]
__all__ = ["SessionPanel"]
+85 -267
View File
@@ -1,224 +1,13 @@
import sys
from dataclasses import dataclass
from enum import Enum, auto
from typing import Callable, Optional
import wx
from wx.lib.pubsub import pub
def enable_high_dpi_support():
if sys.platform == "win32":
try:
import ctypes
ctypes.windll.shcore.SetProcessDpiAwareness(2)
except Exception:
try:
import ctypes
ctypes.windll.user32.SetProcessDPIAware()
except Exception:
pass
class CardState(Enum):
SETTER_NOT_SET = auto()
SETTER_HIDDEN = auto()
SETTER_REVEALED = auto()
GUESSER_DISABLED = auto()
GUESSER_WAITING = auto()
GUESSER_CORRECT = auto()
GUESSER_WRONG = auto()
class ArrowDirection(Enum):
LEFT = auto()
RIGHT = auto()
@dataclass
class SessionStats:
total: int
setted: int
correct: int
wrong: int
taken_time: float
@property
def accuracy(self) -> float:
accuracy: float
if self.total == 0:
accuracy = 0.0
accuracy = (self.correct / self.total) * 100
return round(accuracy, 1)
class StepState(Enum):
WAITING_FOR_SETTER = auto()
WAITING_FOR_GUESSER = auto()
SESSION_FINISHED = auto()
class PlayerRole(Enum):
SETTER = auto()
GUESSER = auto()
@dataclass(frozen=True)
class MoveResult:
step_index: int
setter_direction: Optional[ArrowDirection] = None
setter_card_state: Optional[CardState] = None
guesser_direction: Optional[ArrowDirection] = None
guesser_card_state: Optional[CardState] = None
@dataclass
class Player:
name: str
role: PlayerRole
class SessionState:
def __init__(
self,
local_player: Player,
remote_player: Player,
sequence_length: int = 20,
):
self._sequence_length = sequence_length
self._local_player = local_player
self._remote_player = remote_player
self._completed_step_count: int = 0
self._step_state: StepState = StepState.WAITING_FOR_SETTER
self._setter_moves: list[ArrowDirection] = []
self._guesser_moves: list[ArrowDirection] = []
self._stats: Optional[SessionStats] = None
def setter_move(self, direction: ArrowDirection) -> bool:
if self._step_state != StepState.WAITING_FOR_SETTER:
return False
self._setter_moves.append(direction)
self._step_state = StepState.WAITING_FOR_GUESSER
step_index = len(self._setter_moves) - 1
result = MoveResult(
step_index=step_index,
setter_direction=direction,
setter_card_state=self.get_setter_card_state(step_index),
)
pub.sendMessage("session.setter_moved", result=result)
pub.sendMessage(
"session.step_state_changed",
step_state=self._step_state,
)
return True
def guesser_move(self, direction: ArrowDirection) -> bool:
if self._step_state != StepState.WAITING_FOR_GUESSER:
return False
self._guesser_moves.append(direction)
self._completed_step_count += 1
step_index = len(self._guesser_moves) - 1
result = MoveResult(
step_index=step_index,
setter_direction=self._setter_moves[-1],
setter_card_state=self.get_setter_card_state(step_index),
guesser_direction=self._guesser_moves[-1],
guesser_card_state=self.get_guesser_card_state(step_index),
)
pub.sendMessage("session.guesser_moved", result=result)
if self._completed_step_count < self._sequence_length:
self._step_state = StepState.WAITING_FOR_SETTER
pub.sendMessage(
"session.step_state_changed",
step_state=self._step_state,
)
else:
self._step_state = StepState.SESSION_FINISHED
pub.sendMessage("session.session_finished")
return True
def local_move(self, direction: ArrowDirection) -> bool:
if not self.can_local_move():
return False
if self._local_player.role == PlayerRole.SETTER:
return self.setter_move(direction)
else:
return self.guesser_move(direction)
def can_local_move(self) -> bool:
match self._step_state:
case StepState.WAITING_FOR_SETTER:
return self._local_player.role == PlayerRole.SETTER
case StepState.WAITING_FOR_GUESSER:
return self._local_player.role == PlayerRole.GUESSER
case StepState.SESSION_FINISHED:
return False
def get_setter_card_state(self, step_index: int) -> CardState:
setter_step_index = len(self._setter_moves) + 1
if step_index > setter_step_index:
return CardState.SETTER_NOT_SET
if step_index == setter_step_index:
if self._step_state == StepState.WAITING_FOR_GUESSER:
if self._local_player.role == PlayerRole.SETTER:
return CardState.SETTER_REVEALED
else:
return CardState.SETTER_HIDDEN
return CardState.SETTER_REVEALED
def get_guesser_card_state(self, step_index: int) -> CardState:
if step_index > self._completed_step_count:
return CardState.GUESSER_DISABLED
elif step_index == self._completed_step_count:
return CardState.GUESSER_WAITING
else:
guesser_move = self._guesser_moves[step_index]
setter_move = self._setter_moves[step_index]
if guesser_move == setter_move:
return CardState.GUESSER_CORRECT
else:
return CardState.GUESSER_WRONG
@property
def setter_name(self) -> str:
if self._local_player.role == PlayerRole.SETTER:
return self._local_player.name
else:
return self._remote_player.name
@property
def guesser_name(self) -> str:
if self._local_player.role == PlayerRole.GUESSER:
return self._local_player.name
else:
return self._remote_player.name
@property
def sequence_lenght(self) -> int:
return self._sequence_length
from ...domain import ArrowDirection, CardState, SessionStats, StepData
from ...input import SessionInput
from ...state import SessionState
@dataclass(frozen=True)
@@ -240,6 +29,7 @@ class GuesserCardColour:
class ArrowSymbol:
LEFT: str = ""
RIGHT: str = ""
HIDDEN: str = "?"
class Card(wx.Panel):
@@ -272,7 +62,7 @@ class Card(wx.Panel):
case CardState.SETTER_HIDDEN:
colour = SetterCardColour.HIDDEN
show_arrow = False
show_arrow = True
case CardState.SETTER_REVEALED:
colour = SetterCardColour.REVEALED
@@ -302,6 +92,9 @@ class Card(wx.Panel):
case ArrowDirection.RIGHT:
symbol = ArrowSymbol.RIGHT
case ArrowDirection.HIDDEN:
symbol = ArrowSymbol.HIDDEN
self.label.SetLabel(symbol)
else:
@@ -323,26 +116,35 @@ class Card(wx.Panel):
class SessionPanel(wx.Panel):
def __init__(self, parent, state: SessionState):
def __init__(
self,
parent,
input: SessionInput,
setter_name: str,
guesser_name: str,
sequence_length: int,
):
super().__init__(parent)
self._state = state
self._input = input
self._setter_name = self._state.setter_name
self._guesser_name = self._state.guesser_name
self._sequence_length = self._state.sequence_lenght
self._setter_name = setter_name
self._guesser_name = guesser_name
self._sequence_length = sequence_length
self._setter_sequence: list[Card] = []
self._guesser_sequence: list[Card] = []
self._statusbar = self._get_statusbar()
self._setup_statusbar()
self._setup()
self._fit_parent_frame()
pub.subscribe(self._on_session_started, "session.session_started")
pub.subscribe(self._on_setter_moved, "session.setter_moved")
pub.subscribe(self._on_guesser_moved, "session.guesser_moved")
pub.subscribe(self._on_step_state_changed, "session.step_state_changed")
pub.subscribe(self._on_step_closed, "session.step_closed")
pub.subscribe(self._on_session_finished, "session.session_finished")
def _setup(self) -> None:
@@ -434,6 +236,22 @@ class SessionPanel(wx.Panel):
return None
def _setup_statusbar(self) -> None:
if not self._statusbar:
return
message = " Ожидание начала игры... "
progress = f" Прогресс 0/{self._sequence_length} "
accuracy = " Точность 0.0% (0/0) "
taken_time = " Время 00:00 "
self._statusbar.SetStatusText(message, 0)
self._statusbar.SetStatusText(progress, 1)
self._statusbar.SetStatusText(accuracy, 2)
self._statusbar.SetStatusText(taken_time, 3)
self._autofit_statusbar([progress, accuracy, message])
def _autofit_statusbar(self, texts: list[str]) -> None:
if not self._statusbar:
return
@@ -461,30 +279,59 @@ 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_setter_moved(self, result: MoveResult):
def _on_session_started(self, step: StepData) -> None:
self.update_setter_card(
index=result.step_index,
state=result.setter_card_state or CardState.SETTER_NOT_SET,
direction=result.setter_direction,
)
def _on_guesser_moved(self, result: MoveResult):
self.update_setter_card(
index=result.step_index,
state=result.setter_card_state or CardState.SETTER_NOT_SET,
direction=result.setter_direction,
step.index,
step.setter.state,
step.setter.direction,
)
self.update_guesser_card(
index=result.step_index,
state=result.guesser_card_state or CardState.GUESSER_DISABLED,
direction=result.guesser_direction,
step.index,
step.guesser.state,
step.guesser.direction,
)
def _on_step_state_changed(self, step_state: StepState):
pass
def _on_setter_moved(self, step: StepData) -> None:
self.update_setter_card(
step.index,
step.setter.state,
step.setter.direction,
)
self.update_guesser_card(
step.index,
step.guesser.state,
step.guesser.direction,
)
def _on_guesser_moved(self, step: StepData):
self.update_setter_card(
step.index,
step.setter.state,
step.setter.direction,
)
self.update_guesser_card(
step.index,
step.guesser.state,
step.guesser.direction,
)
def _on_step_closed(self, step: StepData):
self.update_setter_card(
step.index,
step.setter.state,
step.setter.direction,
)
self.update_guesser_card(
step.index,
step.guesser.state,
step.guesser.direction,
)
def _on_session_finished(self):
pass
@@ -530,32 +377,3 @@ class SessionPanel(wx.Panel):
self._statusbar.SetStatusText(taken_time, 3)
self._autofit_statusbar([progress, accuracy, taken_time])
class TestFrame(wx.Frame):
def __init__(self):
super().__init__(None, title="TestFrame")
local = Player("Бывалый", PlayerRole.GUESSER)
remote = Player("Балбес", PlayerRole.SETTER)
session = SessionState(local, remote)
main_panel = SessionPanel(self, session)
main_sizer = wx.BoxSizer(wx.VERTICAL)
main_sizer.Add(main_panel, 1, wx.EXPAND)
self.SetSizer(main_sizer)
s = SessionStats(20, 1, 0, 1, 5.1)
main_panel.update_statusbar(None, s)
session.setter_move(ArrowDirection.LEFT)
session.guesser_move(ArrowDirection.LEFT)
session.setter_move(ArrowDirection.LEFT)
if __name__ == "__main__":
enable_high_dpi_support()
app = wx.App()
frame = TestFrame()
frame.Show()
app.MainLoop()