import 'dart:async'; import 'dart:convert'; import 'dart:isolate'; import 'package:backend/game_room_manager.dart'; import 'package:dart_frog_web_socket/dart_frog_web_socket.dart'; import 'package:logging/logging.dart'; import 'package:shared_models/room.dart'; final _logger = Logger('SocketManager'); class SocketManager { // Default constructor returns instance factory SocketManager() { return instance; } // Private constructor SocketManager._(); // Singleton instance static final SocketManager instance = SocketManager._(); // Store connections: GameRoomUuid -> UserUuid -> WebSocketChannel final _connections = <String, Map<String, WebSocketChannel>>{}; // Store isolate port and stream subscription to said port final _gameRoomSendPorts = <String, SendPort>{}; final _gameRoomPortSubs = <String, StreamSubscription<dynamic>>{}; final _gameRoomUserWsSubs = <String, StreamSubscription<dynamic>>{}; // Add a new connection Future<Status> addConnection( WebSocketChannel connection, { required String roomUuid, required String userUuid, }) async { _logger.finer('Adding connection to socket manager for user $userUuid in room $roomUuid'); if (!_gameRoomPortSubs.containsKey(roomUuid)) { final status = await spawnGameRoomIsolate(roomUuid); if (status == Status.failure) { return status; } } _connections.putIfAbsent(roomUuid, () => <String, WebSocketChannel>{}); _connections[roomUuid]![userUuid] = connection; _logger.fine('Listening to websocket for messages'); // ignore: cancel_subscriptions final sub = connection.stream.listen(_gameRoomMessageListener); _gameRoomUserWsSubs[userUuid] = sub; return Status.success; } // Remove a connection void removeConnection(String roomUuid, String userUuid) { _connections[roomUuid]?.remove(userUuid); if (_connections[roomUuid]?.isEmpty ?? false) { _connections.remove(roomUuid); } } // Get a specific user's connection WebSocketChannel? getConnection(String roomUuid, String userUuid) { return _connections[roomUuid]?[userUuid]; } // Get all connections in a room Map<String, WebSocketChannel>? getRoomConnections(String roomUuid) { return _connections[roomUuid]; } // Broadcast message to all users in a room except sender void broadcastToRoom(String roomUuid, GameRoomMessage message, {String? excludeUserUuid}) { _logger.fine('Broadcasting ${message.type} to room $roomUuid'); _connections[roomUuid]?.forEach((userUuid, connection) { if (message is PingMessage) { message.userUuid = userUuid; } if (userUuid != excludeUserUuid) { connection.sink.add(jsonEncode(message.toJson())); } }); } // Check if a user is connected bool isConnected(String roomUuid, String userUuid) { return _connections[roomUuid]?.containsKey(userUuid) ?? false; } Future<Status> spawnGameRoomIsolate(String roomUuid) async { try { _logger.finest('Spawning isolate for room $roomUuid'); if (_gameRoomSendPorts.containsKey(roomUuid)) { _logger.severe('Tried to create a sendPort for an existing room uuid: $roomUuid', null, StackTrace.current); throw Exception('Cannot create sendPort, room already has one'); } final receivePort = ReceivePort(); final sp = receivePort.sendPort; await Isolate.spawn(LiveGameRoom.spawn, LiveGameRoomData(roomUuid: roomUuid, wsSendPort: sp)); // used to get sendport final completer = Completer<SendPort>(); // ignore: cancel_subscriptions final sub = receivePort.listen((message) => _gameRoomPortListener(message, completer)); _gameRoomPortSubs[roomUuid] = sub; _gameRoomSendPorts[roomUuid] = await completer.future; _logger.info('Spawned new game room $roomUuid, listening to messages from room.'); return Status.success; } catch (e) { _logger.severe('Failed to spawn game room isolate', e, StackTrace.current); _gameRoomSendPorts.remove(roomUuid); final sub = _gameRoomPortSubs.remove(roomUuid); await sub?.cancel(); } return Status.failure; } void _gameRoomPortListener(dynamic message, Completer<SendPort> completer) { // first message from new isolate will be its SendPort if (!completer.isCompleted) { completer.complete(message as SendPort); return; } if (message is GameRoomMessage) { switch (message) { case PlayerVoteMessage(): // TODO: Handle this case. throw UnimplementedError(); case PingMessage(): if (message.dest != PingDestination.client) { _logger.warning('Got room ping meant for server'); return; } broadcastToRoom( message.roomUuid, message, ); } } else { _logger.info('Unknown message: $message'); } } void _gameRoomMessageListener(dynamic message) {} } enum Status { success, failure }