using System; using System.Collections.Generic; using System.Threading.Tasks; using System.Linq; using System.Reflection; using Newtonsoft.Json; using SBRL.Utilities; using Nibriboard.Client.Messages; using Nibriboard.RippleSpace; using SBRL.GlidingSquirrel.Websocket; using System.Net; using Nibriboard.Userspace; namespace Nibriboard.Client { /// /// A delegate that is used in the event that is fired when a nibri client disconnects. /// public delegate void NibriDisconnectedEvent(NibriClient disconnectedClient); /// /// Represents a single client connected to the ripple-space on this Nibriboard server. /// public class NibriClient { #region Id Generation Logic private static int nextId = 1; private static int getNextId() { return nextId++; } /// /// This client's unique id. /// public readonly int Id = getNextId(); #endregion /// /// The nibri client manager /// private readonly NibriboardApp manager; /// /// The plane that this client is currently on. /// public Plane CurrentPlane; /// /// The underlying websocket connection to the client. /// Please try not to call the send method on here - use the NibriClient Send() method instead. /// private readonly WebsocketClient connection; private static readonly Dictionary messageEventTypes = new Dictionary() { ["HandshakeRequest"] = typeof(HandshakeRequestMessage), ["CursorPosition"] = typeof(CursorPositionMessage), ["PlaneChange"] = typeof(PlaneChangeMessage), ["ChunkUpdateRequest"] = typeof(ChunkUpdateRequestMessage), ["LineStart"] = typeof(LineStartMessage), ["LinePart"] = typeof(LinePartMessage), ["LineComplete"] = typeof(LineCompleteMessage), ["LineRemove"] = typeof(LineRemoveMessage), ["ViewportUpdate"] = typeof(ViewportUpdateMessage) }; /// /// Whether this nibri client is still connected. /// public bool Connected { get { return !connection.IsClosing; } } public IPEndPoint RemoteEndpoint { get { return connection.RemoteEndpoint; } } /// /// The user account of the currently connected client. /// public User ConnectedUser { get { if (connection.HandshakeRequest.BasicAuthCredentials == null) return null; return manager.NibriServer.AccountManager.GetByName( connection.HandshakeRequest.BasicAuthCredentials.Username ); } } /// /// Fires when this nibri client disconnects. /// public event NibriDisconnectedEvent Disconnected; /// /// The date and time at which the last message was received from this client. /// public DateTime LastMessageTime = DateTime.Now; /// /// The number of milliseconds since we last received a message from this client. /// /// The milliseconds since last message. public int MillisecondsSinceLastMessage { get { return (int)((DateTime.Now - LastMessageTime).TotalMilliseconds); } } /// /// Whether this client has completed the handshake yet or not. /// public bool HandshakeCompleted = false; /// /// The name this client has assignedto themselves. /// /// The name. public string Name { get; private set; } /// /// The current area that this client is looking at. /// /// The current view port. public Rectangle CurrentViewPort { get; private set; } = Rectangle.Zero; /// /// The absolute position in plane-space of this client's cursor. /// /// The absolute cursor position. public Vector2 AbsoluteCursorPosition { get; private set; } = Vector2.Zero; /// /// This client's colour. Used to tell multiple clients apart visually. /// public readonly ColourHSL Colour = ColourHSL.RandomSaturated(); /// /// The chunk cache. Keeps track of which chunks this client currently has. /// protected ChunkCache chunkCache = new ChunkCache(); #region Core Setup & Message Routing Logic public NibriClient(NibriboardApp inManager, WebsocketClient inClient) { Log.WriteLine( "[Nibriboard/WebSocket] New NibriClient connected with id #{0} for user {1}.", Id, ConnectedUser != null ? ConnectedUser.Username : "Anonymous" ); manager = inManager; connection = inClient; // Attach a few events connection.OnDisconnection += handleDisconnection; connection.OnTextMessage += handleMessage; } private async Task handleMessage(object sender, TextMessageEventArgs eventArgs) { // Update the last time we received a message from the client LastMessageTime = DateTime.Now; // Extract the event name from the message that the client sent. string eventName = JsonUtilities.DeserializeProperty(eventArgs.Payload, "Event"); if(eventName == null) { Log.WriteLine("[NibriClient#{0}] Received message that didn't have an event.", Id); return; } if (!messageEventTypes.ContainsKey(eventName)) { Log.WriteLine("[NibriClient#{0}] Received message with invalid event {1}.", Id, eventName); return; } if(eventName != "CursorPosition") Log.WriteLine("[NibriClient#{0}] Recieved message with event {1}.", Id, eventName); try { Type messageType = messageEventTypes[eventName]; Type jsonNet = typeof(JsonConvert); MethodInfo deserialiserInfo = jsonNet.GetMethods().First(method => method.Name == "DeserializeObject" && method.IsGenericMethod); MethodInfo genericInfo = deserialiserInfo.MakeGenericMethod(messageType); var decodedMessage = genericInfo.Invoke(null, new object[] { eventArgs.Payload }); string handlerMethodName = "handle" + decodedMessage.GetType().Name; Type clientType = this.GetType(); MethodInfo handlerInfo = clientType.GetMethod(handlerMethodName, BindingFlags.Instance | BindingFlags.NonPublic); await (Task)handlerInfo.Invoke(this, new object[] { decodedMessage }); } catch(Exception error) { Log.WriteLine("[NibriClient#{0}] Error decoding and / or handling message.", Id); Log.WriteLine("[NibriClient#{0}] Raw frame content: {1}", Id, eventArgs.Payload); Log.WriteLine("[NibriClient#{0}] Exception details: {1}", Id, error); } } private Task handleDisconnection(object sender, ClientDisconnectedEventArgs eventArgs) { Disconnected?.Invoke(this); Log.WriteLine("[NibriClient] Client #{0} disconnected.", Id); return Task.CompletedTask; } #endregion #region Message Sending /// /// Sends a to the client. /// If you *really* need to send a raw message to the client, you can do so with the SendRawa() method. /// /// The message to send. public void Send(Message message) { try { string payload = JsonConvert.SerializeObject(message); SendRaw(payload); } catch(Exception error) { Log.WriteLine("[NibriClient/#{0}] Error serialising message!", Id); Log.WriteLine("[NibriClient/#{0}] {1}", Id, error); } } /// /// Sends a raw string to the client. Don't use unless you know what you're doing! /// Use the regular Send() method if you can possibly help it. /// /// The message to send. public bool SendRaw(string message) { if (!Connected) { Log.WriteLine($"[NibriClient#{Id}] Can't send a message as the client has disconnected."); return false; } Log.WriteLine("[NibriClient/#{0}] Sending message with length {1}.", Id, message.Length); connection.Send(message); return true; } /// /// Sends a heartbeat message to this client. /// public void SendHeartbeat() { Send(new HeartbeatMessage()); } #endregion /// /// Closes the connection to the client gracefully. /// public async Task CloseConnection(Message lastMessage) { if (!Connected) return; // Tell the client that we're shutting down Send(lastMessage); await connection.Close(WebsocketCloseReason.Normal, "Goodbye!"); } /// /// Generates a new ClientState object representing this client's state at the current time. /// public ClientState GenerateStateSnapshot() { ClientState result = new ClientState(); result.Id = Id; result.Name = Name; result.Colour = Colour; result.CursorPosition = AbsoluteCursorPosition; result.Viewport = CurrentViewPort; return result; } /// /// Determines whether this client can see the chunk at the specified chunk reference. /// /// The chunk reference to check the visibility of. /// Whether this client can see the chunk located at the specified chunk reference public bool CanSee(ChunkReference chunkRef) { if(chunkRef.Plane != CurrentPlane) return false; Rectangle chunkArea = chunkRef.InPlanespaceRectangle(); return chunkArea.Overlap(CurrentViewPort); } #region Message Handlers /// /// Handles an incoming handshake request. We should only receive one of these! /// protected Task handleHandshakeRequestMessage(HandshakeRequestMessage message) { CurrentViewPort = message.InitialViewport; AbsoluteCursorPosition = message.InitialAbsCursorPosition; // Tell everyone else about the new client ClientStatesMessage newClientNotification = new ClientStatesMessage(); newClientNotification.ClientStates.Add(GenerateStateSnapshot()); manager.Broadcast(this, newClientNotification); // Send the new client a response to their handshake request HandshakeResponseMessage handshakeResponse = new HandshakeResponseMessage(); handshakeResponse.Id = Id; handshakeResponse.Colour = Colour; foreach(Plane plane in manager.NibriServer.PlaneManager.Planes) handshakeResponse.Planes.Add(plane.Name); Send(handshakeResponse); // Tell the new client about everyone else who's connected // FUTURE: If we need to handle a large number of connections, we should generate this message based on the chunks surrounding the client Send(GenerateClientStateUpdate()); return Task.CompletedTask; } /// /// Handles an incoming plane change request. /// protected async Task handlePlaneChangeMessage(PlaneChangeMessage message) { Log.WriteLine("[NibriClient#{0}] Changing to plane {1}.", Id, message.NewPlaneName); // Create a new plane with the specified name if it doesn't exist already // future we might want to allow the user to specify the chunk size if(manager.NibriServer.PlaneManager[message.NewPlaneName] == default(Plane)) manager.NibriServer.PlaneManager.CreatePlane(new PlaneInfo(message.NewPlaneName)); // Remove the event listener from the old plane if there is indeed an old plane to remove it from if(CurrentPlane != null) CurrentPlane.OnChunkUpdate -= handleChunkUpdateEvent; // Swap out the current plane CurrentPlane = manager.NibriServer.PlaneManager[message.NewPlaneName]; // Attach a listener to the new plane CurrentPlane.OnChunkUpdate += handleChunkUpdateEvent; // Tell the client that the switch over all went according to plan Send(new PlaneChangeOkMessage() { NewPlaneName = message.NewPlaneName, GridSize = CurrentPlane.ChunkSize }); // Reset the position to (0, 0) since we've just changed planes Rectangle workingViewport = CurrentViewPort; workingViewport.X = 0; workingViewport.Y = 0; CurrentViewPort = workingViewport; List initialChunks = new List(); ChunkReference currentChunkRef = new ChunkReference( CurrentPlane, (int)Math.Floor(CurrentViewPort.X / CurrentPlane.ChunkSize), (int)Math.Floor(CurrentViewPort.Y / CurrentPlane.ChunkSize) ); while(CanSee(currentChunkRef)) { while(CanSee(currentChunkRef)) { initialChunks.Add(currentChunkRef); currentChunkRef = currentChunkRef.Clone() as ChunkReference; currentChunkRef.X++; } currentChunkRef.X = (int)Math.Floor(CurrentViewPort.X / CurrentPlane.ChunkSize); currentChunkRef.Y++; } await SendChunks(initialChunks); } /// /// Handles requests from clients for chunk updates. /// /// The message to process. protected async Task handleChunkUpdateRequestMessage(ChunkUpdateRequestMessage message) { List requestedChunkRefs = message.ForgottenChunksAsReferences(this.CurrentPlane); chunkCache.Remove(requestedChunkRefs); await SendChunks(requestedChunkRefs); } /// /// Handles an incoming cursor position message from the client.. /// /// The message to process. protected Task handleCursorPositionMessage(CursorPositionMessage message) { AbsoluteCursorPosition = message.AbsCursorPosition; // Send the update to the other clients // TODO: Buffer these updates and send them about 5 times a second ClientStatesMessage updateMessage = new ClientStatesMessage(); updateMessage.ClientStates.Add(this.GenerateStateSnapshot()); manager.BroadcastPlane(this, updateMessage); return Task.CompletedTask; } /// /// Handles viewport updates from the remote client. /// /// The viewport update message to handle. protected Task handleViewportUpdateMessage(ViewportUpdateMessage message) { CurrentViewPort = message.NewViewport; return Task.CompletedTask; } /// /// Handles line start events from the client. /// These messages are currently only required to let other clients know about /// lines that are being drawn and their properties for live display. /// /// The LineStartMessage to process. protected Task handleLineStartMessage(LineStartMessage message) { // Create a new line manager.LineIncubator.CreateLine( message.LineId, message.LineColour, message.LineWidth ); manager.BroadcastPlane(this, new LineStartReflectionMessage() { OtherClientId = Id, LineId = message.LineId, LineColour = message.LineColour, LineWidth = message.LineWidth }); return Task.CompletedTask; } /// /// Handles messages containing a fragment of a line from the client. /// /// The message to process. protected Task handleLinePartMessage(LinePartMessage message) { // Forward the line part to everyone on this plane manager.BroadcastPlane(this, message); List linePoints = new List(message.Points.Count); foreach(Vector2 point in message.Points) linePoints.Add(new LocationReference(CurrentPlane, point.X, point.Y)); manager.LineIncubator.AddBit(message.LineId, linePoints); manager.BroadcastPlane(this, new LinePartReflectionMessage() { OtherClientId = Id, LineId = message.LineId, Points = message.Points }); return Task.CompletedTask; } /// /// Handles notifications from clients telling us that they've finished drawing a line. /// /// The message to handle. protected async Task handleLineCompleteMessage(LineCompleteMessage message) { // If the line doesn't exist, then ignore it if(!manager.LineIncubator.LineExists(message.LineId)) { Log.WriteLine("[NibriClient#{0}/handlers] Ignoring LineComplete event for line that doesn't exist", Id); return; } DrawnLine line = manager.LineIncubator.CompleteLine(message.LineId); if(CurrentPlane == null) { Log.WriteLine("[NibriClient#{0}] Attempt to complete a line before selecting a plane - ignoring"); Send(new ErrorMessage() { Message = "Error: You can't complete a line until you've selected a plane " + "to draw it on!" }); return; } Log.WriteLine("[NibriClient#{0}] Adding {1}px {2} line", Id, line.Width, line.Colour); manager.BroadcastPlane(this, new LineCompleteReflectionMessage() { OtherClientId = Id, LineId = line.LineId }); await CurrentPlane.AddLine(line); } /// /// Handles messages requesting that a line be removed from a chunk. /// /// The message to handle. protected async Task handleLineRemoveMessage(LineRemoveMessage message) { bool removeSuccess = await CurrentPlane.RemoveLineSegment( message.ConvertedContainingChunk(CurrentPlane), message.UniqueId ); Log.WriteLine("[NibriClient#{1}] " + (removeSuccess ? "Removed" : "Failed to remove") + " line segment with unique id {0} from {1}", message.UniqueId, message.ConvertedContainingChunk(CurrentPlane)); } #endregion #region RippleSpace Event Handlers protected void handleChunkUpdateEvent(object sender, ChunkUpdateEventArgs eventArgs) { Chunk sendingChunk = sender as Chunk; Log.WriteLine("[NibriClient#{0}] Sending chunk update for {1}", Id, sendingChunk.Location); ChunkUpdateMessage clientNotification = new ChunkUpdateMessage() { Chunks = new List() { sendingChunk } }; Send(clientNotification); } #endregion /// /// Generates an update message that contains information about the locations and states of all connected clients. /// Automatically omits information about the current client, and clients on other planes. /// /// The client state update message. protected ClientStatesMessage GenerateClientStateUpdate() { ClientStatesMessage result = new ClientStatesMessage(); foreach (NibriClient otherClient in manager.NibriClients) { // Don't include ourselves in the update message! if (otherClient == this) continue; // Only include other nibri clients on our plane if(otherClient.CurrentPlane != CurrentPlane) continue; result.ClientStates.Add(otherClient.GenerateStateSnapshot()); } return result; } /// /// Sends variable list of chunks to this client. /// Automatically fetches the chunks by reference from the current plane. /// /// The references of the chunks to send. protected async Task SendChunks(IEnumerable chunkRefs) { if(CurrentPlane == default(Plane)) { Send(new ExceptionMessage("You're not on a plane yet, so you can't request chunks." + "Try joining a plane and sending that request again.")); return; } if(chunkRefs.Count() == 0) { Log.WriteLine("[NibriClient#{0}/SendChunks] Can't send 0 chunks!", Id); return; } // Keep track of the fact that we've sent the client a bunch of chunks chunkCache.Add(chunkRefs); ChunkUpdateMessage updateMessage = new ChunkUpdateMessage(); foreach(ChunkReference chunkRef in chunkRefs) updateMessage.Chunks.Add(await CurrentPlane.FetchChunk(chunkRef)); Log.WriteLine("[NibriClient#{0}/SendChunks] Sending {1} chunks", Id, updateMessage.Chunks.Count); Send(updateMessage); } } }