using System; using System.Collections.Generic; using System.Linq; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; namespace GeoSus.Client { // Hlavní klientská třída pro připojení k serveru public class GameClient : IDisposable { private TcpClient? _tcpClient; private NetworkStream? _stream; private ClientEncryption? _encryption; private CancellationTokenSource? _cts; private Task? _receiveTask; private int _clientSeq; private readonly object _sendLock = new object(); private bool _handshakeComplete; public string ClientUuid { get; } public string DisplayName { get; set; } public bool IsConnected => _tcpClient?.Connected ?? false; public bool IsReady => IsConnected && _handshakeComplete && (_encryption?.HasSessionKey ?? false); public EventDispatcher Dispatcher { get; } // Events - voláno na main thread přes dispatcher public event Action? OnConnected; public event Action? OnDisconnected; public event Action? OnError; public event Action? OnMessage; public event Action? OnGameEvent; // Lobby state public string? LobbyId { get; private set; } public string? JoinCode { get; private set; } public LobbyState? CurrentLobbyState { get; private set; } public PlayerRole? MyRole { get; private set; } public List MyTasks { get; } = new List(); public Position MyPosition { get; set; } public Dictionary PlayerPositions { get; } = new Dictionary(); public List Bodies { get; } = new List(); public int Ping { get; private set; } public long LastEventId { get; private set; } /// Returns true if this client is the current lobby owner public bool IsOwner => CurrentLobbyState?.OwnerId == ClientUuid; public GameClient(string clientUuid, string displayName) { ClientUuid = clientUuid; DisplayName = displayName; Dispatcher = new EventDispatcher(); } #region Connection public async Task ConnectAsync(string host, int port) { try { _tcpClient = new TcpClient(); await _tcpClient.ConnectAsync(host, port); _stream = _tcpClient.GetStream(); _encryption = new ClientEncryption(); _cts = new CancellationTokenSource(); // Handshake if (!await PerformHandshakeAsync()) { Disconnect("Handshake failed"); return false; } // Spustíme příjem zpráv _receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token)); Dispatcher.Post(() => OnConnected?.Invoke()); return true; } catch (Exception ex) { Dispatcher.Post(() => OnError?.Invoke(ex.Message)); return false; } } private async Task PerformHandshakeAsync() { if (_stream == null || _encryption == null) return false; // 1. ClientHello var hello = new ClientHello { ClientUuid = ClientUuid, DisplayName = DisplayName }; await SendPlainAsync(hello); // 2. ServerHello var serverHelloData = await ReadMessageAsync(); if (serverHelloData == null) return false; var serverHello = MessageSerializer.Deserialize(serverHelloData) as ServerHello; if (serverHello == null) return false; // 3. Generujeme session key a šifrujeme RSA _encryption.GenerateSessionKey(); var (encKey, encIv) = _encryption.EncryptSessionKeyForServer(serverHello.RsaPublicKeyPem); var keyExchange = new KeyExchange { EncryptedSessionKey = encKey, EncryptedIV = encIv }; await SendPlainAsync(keyExchange); // 4. KeyExchangeAck (šifrovaně) var ackData = await ReadMessageAsync(); if (ackData == null) return false; var decrypted = _encryption.Decrypt(ackData); if (decrypted == null) return false; var ack = MessageSerializer.Deserialize(decrypted) as KeyExchangeAck; if (ack?.Status == "success") { _handshakeComplete = true; return true; } return false; } /// /// Tears down the socket and crypto session. When `transient` is true /// (network drop, decrypt-failure cascade, anything we expect to retry), /// the lobby/role/task/state caches are preserved so the post-reconnect /// flow can re-associate via Reconnect(LobbyId). Default false matches /// pre-P9 behavior (full state wipe) for explicit user disconnects. /// /// Critical for the P9 reconnect bug: previously every Disconnect path /// nuked LobbyId, so by the time GameManager_Network's reconnect coroutine /// fired, the client had no idea which lobby it had been in - the /// post-handshake Reconnect call had nothing to send and the server /// answered the next vote/action with NOT_IN_LOBBY. /// public void Disconnect(string reason = "User disconnected", bool transient = false) { _cts?.Cancel(); _tcpClient?.Close(); _tcpClient = null; _stream = null; _encryption?.Dispose(); _encryption = null; if (!transient) { LobbyId = null; JoinCode = null; CurrentLobbyState = null; MyRole = null; MyTasks.Clear(); PlayerPositions.Clear(); Bodies.Clear(); } // PlayerPositions are stale anyway after a drop, but we keep them so // the UI doesn't blink avatars off-map mid-meeting; the next position // broadcast overwrites them. LastEventId is intentionally preserved // so the Reconnect message can ask the server for missed events. Dispatcher.Post(() => OnDisconnected?.Invoke(reason)); } #endregion #region Sending public void Send(Message message) { if (_stream == null || _encryption == null || !IsConnected) return; message.ClientSeq = Interlocked.Increment(ref _clientSeq); if (string.IsNullOrEmpty(message.ActionId)) { message.ActionId = Guid.NewGuid().ToString("N").Substring(0, 8); } var plain = MessageSerializer.Serialize(message); var encrypted = _encryption.Encrypt(plain); lock (_sendLock) { try { SendData(encrypted); } catch (Exception ex) { Dispatcher.Post(() => OnError?.Invoke($"Send error: {ex.Message}")); } } } private async Task SendPlainAsync(Message message) { if (_stream == null) return; var data = MessageSerializer.Serialize(message); await SendDataAsync(data); } private void SendData(byte[] data) { if (_stream == null) return; var lengthBuffer = BitConverter.GetBytes(data.Length); if (BitConverter.IsLittleEndian) Array.Reverse(lengthBuffer); _stream.Write(lengthBuffer, 0, 4); _stream.Write(data, 0, data.Length); _stream.Flush(); } private async Task SendDataAsync(byte[] data) { if (_stream == null) return; var lengthBuffer = BitConverter.GetBytes(data.Length); if (BitConverter.IsLittleEndian) Array.Reverse(lengthBuffer); await _stream.WriteAsync(lengthBuffer, 0, 4); await _stream.WriteAsync(data, 0, data.Length); await _stream.FlushAsync(); } #endregion #region Receiving private async Task ReceiveLoopAsync(CancellationToken ct) { int decryptFailures = 0; try { while (!ct.IsCancellationRequested && IsConnected) { var data = await ReadMessageAsync(); if (data == null) break; var decrypted = _encryption?.Decrypt(data); if (decrypted == null) { decryptFailures++; if (decryptFailures >= 3) { // Transient: keep LobbyId for the reconnect coroutine. Disconnect("Too many decryption failures", transient: true); return; } continue; } decryptFailures = 0; var message = MessageSerializer.Deserialize(decrypted); if (message != null) { ProcessMessage(message); } } } catch (Exception ex) when (!ct.IsCancellationRequested) { // Transient: TCP RST / read failure is exactly what reconnect was // designed for. Keep LobbyId so post-reconnect flow can re-attach. Disconnect($"Connection error: {ex.Message}", transient: true); } } private async Task ReadMessageAsync() { if (_stream == null) return null; var lengthBuffer = new byte[4]; var read = await _stream.ReadAsync(lengthBuffer, 0, 4); if (read < 4) return null; if (BitConverter.IsLittleEndian) Array.Reverse(lengthBuffer); var length = BitConverter.ToInt32(lengthBuffer, 0); if (length <= 0 || length > 1048576) return null; var buffer = new byte[length]; var totalRead = 0; while (totalRead < length) { read = await _stream.ReadAsync(buffer, totalRead, length - totalRead); if (read == 0) return null; totalRead += read; } return buffer; } private void ProcessMessage(Message message) { // Zpracujeme speciální typy switch (message) { case CreateLobbyResponse r: if (r.Success) { LobbyId = r.LobbyId; JoinCode = r.JoinCode; // Ensure we always have a valid lobby state with the creator as owner if (r.LobbyState != null) { CurrentLobbyState = r.LobbyState; if (string.IsNullOrEmpty(CurrentLobbyState.OwnerId)) CurrentLobbyState.OwnerId = ClientUuid; } else { CurrentLobbyState = new LobbyState { LobbyId = r.LobbyId ?? "", JoinCode = r.JoinCode ?? "", OwnerId = ClientUuid }; } // Make sure creator appears in the player list if (CurrentLobbyState.Players == null) CurrentLobbyState.Players = new System.Collections.Generic.List(); if (!CurrentLobbyState.Players.Any(p => p.ClientUuid == ClientUuid)) { CurrentLobbyState.Players.Insert(0, new PlayerInfo { ClientUuid = ClientUuid, DisplayName = DisplayName, IsOwner = true, State = PlayerState.Alive }); } } break; case JoinLobbyResponse r: if (r.Success) { LobbyId = r.LobbyId; CurrentLobbyState = r.LobbyState; JoinCode = r.LobbyState?.JoinCode; // Ensure self is in the player list if (CurrentLobbyState != null) { if (CurrentLobbyState.Players == null) CurrentLobbyState.Players = new System.Collections.Generic.List(); if (!CurrentLobbyState.Players.Any(p => p.ClientUuid == ClientUuid)) { CurrentLobbyState.Players.Add(new PlayerInfo { ClientUuid = ClientUuid, DisplayName = DisplayName, IsOwner = CurrentLobbyState.OwnerId == ClientUuid, State = PlayerState.Alive }); } } } break; case PositionBroadcast b: ProcessPositionBroadcast(b); break; case Pong p: var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); Ping = (int)(now - p.ClientTime); break; case GameEvent evt: ProcessGameEvent(evt); Dispatcher.Post(() => OnGameEvent?.Invoke(evt)); break; } Dispatcher.Post(() => OnMessage?.Invoke(message)); } private void ProcessPositionBroadcast(PositionBroadcast broadcast) { PlayerPositions.Clear(); foreach (var player in broadcast.Players) { PlayerPositions[player.ClientUuid] = player; } } private void ProcessGameEvent(GameEvent evt) { LastEventId = evt.EventId; switch (evt.EventType) { case "PlayerJoined": // Add player to lobby state var joinedPayload = evt.GetPayload(); if (joinedPayload != null && CurrentLobbyState?.Players != null) { bool exists = CurrentLobbyState.Players.Any(p => p.ClientUuid == joinedPayload.ClientUuid); if (!exists) { CurrentLobbyState.Players.Add(new PlayerInfo { ClientUuid = joinedPayload.ClientUuid, DisplayName = joinedPayload.DisplayName, IsOwner = joinedPayload.ClientUuid == CurrentLobbyState.OwnerId, IsReady = false, State = PlayerState.Alive }); } } break; case "PlayerLeft": // Remove player from lobby state var leftPayload = evt.GetPayload(); if (leftPayload != null && CurrentLobbyState?.Players != null) { CurrentLobbyState.Players.RemoveAll(p => p.ClientUuid == leftPayload.ClientUuid); } break; case "HostChanged": // Update lobby owner var hostPayload = evt.GetPayload(); if (hostPayload != null && CurrentLobbyState != null) { CurrentLobbyState.OwnerId = hostPayload.NewHostId; // Update IsOwner flag on all players foreach (var player in CurrentLobbyState.Players) { player.IsOwner = player.ClientUuid == hostPayload.NewHostId; } } break; case "GameStarting": // Game is entering loading phase - update lobby state if available if (CurrentLobbyState != null) { CurrentLobbyState.Phase = GamePhase.Loading; } break; case "MapDataReady": // Map data received - store it and send confirmation var mapDataPayload = evt.GetPayload(); if (mapDataPayload != null && CurrentLobbyState != null) { CurrentLobbyState.MapData = mapDataPayload.MapData; CurrentLobbyState.MapDataReady = true; } // Send confirmation to server Send(new MapDataReceived()); break; case "GameStarted": // Game officially started - update phase if (CurrentLobbyState != null) { CurrentLobbyState.Phase = GamePhase.Playing; } break; case "RoleAssigned": var rolePayload = evt.GetPayload(); if (rolePayload != null && rolePayload.ClientUuid == ClientUuid) { MyRole = rolePayload.Role; MyTasks.Clear(); if (rolePayload.Tasks != null) { MyTasks.AddRange(rolePayload.Tasks); } } break; case "PlayerKilled": var killPayload = evt.GetPayload(); if (killPayload != null) { Bodies.Add(new Body { BodyId = killPayload.BodyId, VictimId = killPayload.VictimId, Location = killPayload.Location }); } break; case "MeetingStarted": if (CurrentLobbyState != null) { CurrentLobbyState.Phase = GamePhase.Meeting; } break; case "VotingClosed": Bodies.Clear(); // Bodies zmizí po meetingu if (CurrentLobbyState != null) { CurrentLobbyState.Phase = GamePhase.Playing; } break; case "GameEnded": if (CurrentLobbyState != null) { CurrentLobbyState.Phase = GamePhase.Ended; } break; } } #endregion #region Game Actions public void CreateLobby(Position? center = null, int impostorCount = 1, int taskCount = 5, string? password = null, double playAreaRadius = 500, GameSettingsOverrides? settings = null) { Send(new CreateLobby { PlayAreaCenter = center, PlayAreaRadius = playAreaRadius, ImpostorCount = impostorCount, TaskCount = taskCount, Password = password, Settings = settings }); } public void JoinLobby(string joinCode, string? password = null) { Send(new JoinLobby { JoinCode = joinCode.ToUpperInvariant(), Password = password }); } public void LeaveLobby() { Send(new LeaveLobby()); LobbyId = null; JoinCode = null; CurrentLobbyState = null; } public void StartGame() { Send(new StartGame()); } public void ReturnToLobby() { Send(new ReturnToLobby()); } public void UpdatePosition(Position position) { MyPosition = position; Send(new UpdatePosition { Position = position }); } public void Kill(string targetUuid) { Send(new KillAttempt { TargetClientUuid = targetUuid }); } public void ReportBody(string bodyId) { Send(new ReportBody { BodyId = bodyId }); } public void CallEmergencyMeeting() { Send(new CallEmergencyMeeting()); } public void Vote(string? targetUuid) { Send(new CastVote { TargetClientUuid = targetUuid }); } /// /// Pokus o dokončení tasku. Server ověří že hráč je na správné pozici. /// public void CompleteTask(string taskId) { Send(new TaskComplete { TaskId = taskId }); } public void SendPing() { Send(new Ping { ClientTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); } public void Reconnect(string lobbyId) { Send(new Reconnect { LobbyId = lobbyId, LastEventId = LastEventId }); } #endregion #region Helpers public Body? FindNearbyBody(double maxDistance) { foreach (var body in Bodies) { if (MyPosition.DistanceTo(body.Location) <= maxDistance) { return body; } } return null; } public string? FindNearbyPlayer(double maxDistance, bool aliveOnly = true) { foreach (var (uuid, info) in PlayerPositions) { if (uuid == ClientUuid) continue; if (aliveOnly && info.State != PlayerState.Alive) continue; if (MyPosition.DistanceTo(info.Position) <= maxDistance) { return uuid; } } return null; } public GameTask? FindNearbyTask(double maxDistance) { foreach (var task in MyTasks) { if (MyPosition.DistanceTo(task.Location) <= maxDistance) { return task; } } return null; } // Volat z Unity Update() pro zpracování callbacků public void Update() { Dispatcher.ProcessPendingActions(); } #endregion public void Dispose() { Disconnect("Disposed"); _encryption?.Dispose(); } } }