diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..d71fccd --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,8 @@ +Style/StringLiterals: + EnforcedStyle: double_quotes + +Metrics/MethodLength: + Max: 40 + +Style/EmptyMethod: + EnforcedStyle: expanded \ No newline at end of file diff --git a/lib/networking.rb b/lib/networking.rb index 13d7a6f..cdd4738 100644 --- a/lib/networking.rb +++ b/lib/networking.rb @@ -1,4 +1,4 @@ -class IMICFPS +module CyberarmEngine module Networking MULTICAST_ADDRESS = "224.0.0.1" MULTICAST_PORT = 30_000 @@ -6,7 +6,7 @@ class IMICFPS REMOTE_GAMEHUB = "i-mic.cyberarm.dev" REMOTE_GAMEHUB_PORT = 98765 - DEFAULT_SERVER_HOST = "0.0.0.0" + DEFAULT_SERVER_HOSTNAME = "0.0.0.0" DEFAULT_SERVER_PORT = 56789 DEFAULT_SERVER_QUERY_PORT = 28900 diff --git a/lib/networking/backend/channel.rb b/lib/networking/backend/channel.rb index a206154..f7f3e37 100644 --- a/lib/networking/backend/channel.rb +++ b/lib/networking/backend/channel.rb @@ -1,6 +1,8 @@ module CyberarmEngine module Networking class Channel + def initialize(id:, mode:) + end end end -end \ No newline at end of file +end diff --git a/lib/networking/backend/connection.rb b/lib/networking/backend/connection.rb index 6f5f93c..8e0c83d 100644 --- a/lib/networking/backend/connection.rb +++ b/lib/networking/backend/connection.rb @@ -1,31 +1,78 @@ module CyberarmEngine module Networking class Connection - def initialize(hostname:, port:, max_clients:, channels: 1) + def initialize(hostname:, port:, channels: 3) + @hostname = hostname + @port = port + + @channels = Array(0..channels).map { |id| Channel.new(id: id, mode: :default) } + + @peer = Peer.new(id: 0, hostname: "", port: "") + + @last_read_time = Networking.milliseconds + @last_write_time = Networking.milliseconds + + @total_packets_sent = 0 + @total_packets_received = 0 + @total_data_sent = 0 + @total_data_received = 0 end # Callbacks # def connected end - def disconnected(reason) + def disconnected(reason:) end def reconnected end - def packet_received(message, channel) + def packet_received(message:, channel:) end # Functions # - def send_packet(message, reliable, channel = 0) + def send_packet(message:, reliable: false, channel: 0) end - def broadcast_packet(message, reliable, channel = 0) + def connect(timeout: Protocol::TIMEOUT_PERIOD) + @socket = UDPSocket.new + + write(PacketHandler.create_control_packet(peer: @peer, control_type: Protocol::CONTROL_CONNECT)) end - def disconnect(reason = "") + def disconnect(timeout: Protocol::TIMEOUT_PERIOD) + end + + def update + while read + end + end + + private + + def read + data, addr = @socket.recvfrom_nonblock(Protocol::MAX_PACKET_SIZE) + pkt = PacketHandler.handle(host: self, raw: data, peer: @peer) + packet_received(message: pkt.message, channel: -1) if pkt.is_a?(RawPacket) + + @total_packets_received += 1 + @total_data_received += data.length + @last_read_time = Networking.milliseconds + + return true + rescue IO::WaitReadable + return false + end + + def write(packet) + raw = packet.encode + @socket.send(raw, 0, @hostname, @port) + + @total_packets_sent += 1 + @total_data_sent += raw.length + @last_write_time = Networking.milliseconds end end end -end \ No newline at end of file +end diff --git a/lib/networking/backend/packet.rb b/lib/networking/backend/packet.rb index 0b246f5..1a82cd8 100644 --- a/lib/networking/backend/packet.rb +++ b/lib/networking/backend/packet.rb @@ -1,19 +1,30 @@ module CyberarmEngine module Networking class Packet - attr_reader :protocol_version, :type, :peer_id, :message + attr_reader :protocol_version, :peer_id, :channel, :message - def self.type - raise NotImplementedError, "#{self.class}.type must be defined!" + def self.decode(raw) + header = raw.unpack(CyberarmEngine::Networking::Protocol::PACKET_BASE_HEADER) + + Packet.new(protocol_version: header[0], peer_id: header[1], channel: header[2], message: raw[Protocol::PACKET_BASE_HEADER_LENGTH...raw.length]) end - def self.decode(packet) - raise NotImplementedError, "#{self.class}.decode must be defined!" + def initialize(protocol_version:, peer_id:, channel:, message:) + @protocol_version = protocol_version + @peer_id = peer_id + @channel = channel + @message = message end def encode - raise NotImplementedError, "#{self.class}#encode must be defined!" + header = [ + @protocol_version, + @peer_id, + @channel + ].pack(CyberarmEngine::Networking::Protocol::PACKET_BASE_HEADER) + + "#{header}#{@message}" end end end -end \ No newline at end of file +end diff --git a/lib/networking/backend/packet_handler.rb b/lib/networking/backend/packet_handler.rb index a1edd59..b5fe709 100644 --- a/lib/networking/backend/packet_handler.rb +++ b/lib/networking/backend/packet_handler.rb @@ -1,6 +1,108 @@ module CyberarmEngine module Networking module PacketHandler + def self.type_to_name(type:) + Protocol.constants.select { |const| const.to_s.start_with?("PACKET_") } + .find { |const| Protocol.const_get(const) == type } + end + + def self.handle(host:, raw:, peer:) + packet = Packet.decode(raw) + type = packet.message.unpack1("C") + + puts "#{host.class} received #{type_to_name(type: type)}" + + case type + when Protocol::PACKET_CONTROL + handle_control_packet(host, packet, peer) + when Protocol::PACKET_RAW + handle_raw_packet(packet) + else + raise NotImplementedError, "A Packet handler for #{type} is not implmented!" + end + end + + def self.handle_control_packet(host, packet, peer) + pkt = ControlPacket.decode(packet.message) + + case pkt.control_type + when Protocol::CONTROL_CONNECT # TOSERVER only + if (peer_id = host.available_peer_id) + peer.id = peer_id + host.clients << peer + peer.write_queue << create_control_packet(peer: peer, control_type: Protocol::CONTROL_SET_PEER_ID, message: [peer_id].pack("n")) + host.client_connected(peer: peer) + else + host.write( + peer: peer, + packet: PacketHandler.create_control_packet( + peer: peer, + control_type: Protocol::CONTROL_DISCONNECT, + message: "ERROR: max number of clients already connected" + ) + ) + end + + when Protocol::CONTROL_SET_PEER_ID # TOCLIENT only + peer.id = pkt.message.unpack1("n") + host.connected + + when Protocol::CONTROL_DISCONNECT + if host.is_a?(Server) + host.client_disconnected(peer: peer) + else + host.disconnected(reason: pkt.message) + end + + when Protocol::CONTROL_HEARTBEAT + when Protocol::CONTROL_PING + when Protocol::CONTROL_PONG + end + + nil + end + + def self.handle_raw_packet(packet) + RawPacket.decode(packet.message) + end + + def self.create_control_packet(peer:, control_type:, message: nil, reliable: false, channel: 0) + message_packet = nil + + if reliable + warn "Reliable packets are not yet implemented!" + packet = ControlPacket.new(control_type: control_type, message: message) + message_packet = ReliablePacket.new(sequence_number: peer.next_reliable_sequence_number, message: packet.encode) + else + message_packet = ControlPacket.new(control_type: control_type, message: message) + end + + Packet.new( + protocol_version: Protocol::PROTOCOL_VERSION, + peer_id: peer.id, + channel: channel, + message: message_packet.encode + ) + end + + def self.create_raw_packet(peer:, message:, reliable: false, channel: 0) + message_packet = nil + + if reliable + warn "Reliable packets are not yet implemented!" + packet = RawPacket.new(message: message) + message_packet = ReliablePacket.new(sequence_number: peer.next_reliable_sequence_number, message: packet.encode) + else + message_packet = RawPacket.new(message: message) + end + + Packet.new( + protocol_version: Protocol::PROTOCOL_VERSION, + peer_id: peer.id, + channel: channel, + message: message_packet.encode + ) + end end end -end \ No newline at end of file +end diff --git a/lib/networking/backend/packets/control_packet.rb b/lib/networking/backend/packets/control_packet.rb new file mode 100644 index 0000000..79ba08c --- /dev/null +++ b/lib/networking/backend/packets/control_packet.rb @@ -0,0 +1,32 @@ +module CyberarmEngine + module Networking + class ControlPacket + attr_reader :message, :type, :control_type + + HEADER_PACKER = "CC" + HEADER_LENGTH = 1 + 1 # bytes + + def self.decode(raw_message) + header = raw_message.unpack(HEADER_PACKER) + message = raw_message[HEADER_LENGTH..raw_message.length - 1] + + ControlPacket.new(type: header[0], control_type: header[1], message: message) + end + + def initialize(control_type:, message: nil, type: Protocol::PACKET_CONTROL) + @type = type + @control_type = control_type + @message = message + end + + def encode + header = [ + @type, + @control_type + ].pack(HEADER_PACKER) + + "#{header}#{@message}" + end + end + end +end diff --git a/lib/networking/backend/packets/data_packet.rb b/lib/networking/backend/packets/data_packet.rb deleted file mode 100644 index adf63c9..0000000 --- a/lib/networking/backend/packets/data_packet.rb +++ /dev/null @@ -1,37 +0,0 @@ -module CyberarmEngine - module Networking - class DataPacket < Packet - HEADER_PACKER = "CCn" - HEADER_LENGTH = 1 + 1 + 4 # bytes - - def self.type - Protocol::DATA - end - - def self.decode(raw_message) - header = raw_message.unpack(HEADER_PACKER) - message = raw_message[HEADER_LENGTH..raw_message.length - 1] - - DataPacket.new(protocol_version: header[0], type: header[1], message: message) - end - - def initialize(protocol_version:, type:, peer_id:, message:) - @protocol_version = protocol_version - @type = type - @peer_id = peer_id - - @message = message - end - - def encode - header = [ - Protocol::PROTOCOL_VERSION, - @type, - @peer_id, - ].pack(HEADER_PACKER) - - "#{header}#{message}" - end - end - end -end \ No newline at end of file diff --git a/lib/networking/backend/packets/frament_packet.rb b/lib/networking/backend/packets/frament_packet.rb new file mode 100644 index 0000000..e69de29 diff --git a/lib/networking/backend/packets/raw_packet.rb b/lib/networking/backend/packets/raw_packet.rb new file mode 100644 index 0000000..0f2ca4e --- /dev/null +++ b/lib/networking/backend/packets/raw_packet.rb @@ -0,0 +1,30 @@ +module CyberarmEngine + module Networking + class RawPacket + attr_reader :message, :type + + HEADER_PACKER = "C" + HEADER_LENGTH = 1 # bytes + + def self.decode(raw_message) + header = raw_message.unpack(HEADER_PACKER) + message = raw_message[HEADER_LENGTH..raw_message.length - 1] + + RawPacket.new(type: header[0], message: message) + end + + def initialize(message:, type: Protocol::PACKET_RAW) + @type = type + @message = message + end + + def encode + header = [ + @type + ].pack(HEADER_PACKER) + + "#{header}#{@message}" + end + end + end +end diff --git a/lib/networking/backend/packets/reliable_packet.rb b/lib/networking/backend/packets/reliable_packet.rb new file mode 100644 index 0000000..e8ffae7 --- /dev/null +++ b/lib/networking/backend/packets/reliable_packet.rb @@ -0,0 +1,32 @@ +module CyberarmEngine + module Networking + class ReliablePacket + attr_reader :message, :type, :control_type + + HEADER_PACKER = "Cn" + HEADER_LENGTH = 1 + 2 # bytes + + def self.decode(raw_message) + header = raw_message.unpack(HEADER_PACKER) + message = raw_message[HEADER_LENGTH..raw_message.length - 1] + + ReliablePacket.new(type: header[0], control_type: header[1], message: message) + end + + def initialize(sequence_number:, message:, type: Protocol::PACKET_RELIABLE) + @type = type + @sequence_number = sequence_number + @message = message + end + + def encode + header = [ + @type, + @control_type + ].pack(HEADER_PACKER) + + "#{header}#{@message}" + end + end + end +end diff --git a/lib/networking/backend/peer.rb b/lib/networking/backend/peer.rb index 91f6638..c671f8e 100644 --- a/lib/networking/backend/peer.rb +++ b/lib/networking/backend/peer.rb @@ -1,14 +1,34 @@ module CyberarmEngine module Networking class Peer - attr_reader :id, :hostname, :port, :data + attr_reader :id, :hostname, :port, :data, :read_queue, :write_queue + attr_accessor :total_packets_sent, :total_packets_received, + :total_data_sent, :total_data_received, + :last_read_time, :last_write_time + def initialize(id:, hostname:, port:) @id = id @hostname = hostname @port = port @data = {} + @read_queue = [] + @write_queue = [] + + @last_read_time = Networking.milliseconds + @last_write_time = Networking.milliseconds + + @total_packets_sent = 0 + @total_packets_received = 0 + @total_data_sent = 0 + @total_data_received = 0 + end + + def id=(n) + raise "Peer id must be an integer" unless n.is_a?(Integer) + + @id = n end end end -end \ No newline at end of file +end diff --git a/lib/networking/backend/protocol.rb b/lib/networking/backend/protocol.rb index 5d9c389..a8fa4b9 100644 --- a/lib/networking/backend/protocol.rb +++ b/lib/networking/backend/protocol.rb @@ -1,30 +1,29 @@ module CyberarmEngine module Networking module Protocol - MAX_PACKET_SIZE = 1024 - PROTOCOL_VERSION = 0 # int + MAX_PACKET_SIZE = 1024 # bytes + PROTOCOL_VERSION = 0 # u32 HEARTBEAT_INTERVAL = 5_000 # ms TIMEOUT_PERIOD = 30_000 # ms - packet_types = %w{ - # protocol packets - reliable - multipart - control - data + PACKET_BASE_HEADER = "NnC" # protocol version (u32), sender peer id (u16), channel (u8) + PACKET_BASE_HEADER_LENGTH = 4 + 2 + 1 # bytes - # control packet types - disconnect - acknowledge - heartbeat - ping - } + # protocol packets + PACKET_RELIABLE = 0 + PACKET_FRAGMENT = 1 + PACKET_CONTROL = 2 + PACKET_RAW = 3 - # emulate c-like enum - packet_types.each_with_index do |type, i| - next if type.start_with?("#") - self.const_set(:"#{type.upcase}", i) - end + # control packet types + CONTROL_CONNECT = 30 + CONTROL_SET_PEER_ID = 31 + CONTROL_DISCONNECT = 32 + CONTROL_ACKNOWLEDGE = 33 + CONTROL_HEARTBEAT = 34 + CONTROL_PING = 35 + CONTROL_PONG = 36 + CONTROL_SET_PEER_MTU = 37 # In future end end -end \ No newline at end of file +end diff --git a/lib/networking/backend/server.rb b/lib/networking/backend/server.rb index 8154584..27f83df 100644 --- a/lib/networking/backend/server.rb +++ b/lib/networking/backend/server.rb @@ -1,59 +1,180 @@ -class Server - attr_reader :hostname, :port, :max_clients - def initialize(hostname: "0.0.0.0", port: 56789, max_clients: 32, channels: 1) - @hostname = hostname - @port = port - @max_clients = max_clients +module CyberarmEngine + module Networking + class Server + attr_reader :hostname, :port, :max_clients + attr_accessor :total_packets_sent, :total_packets_received, + :total_data_sent, :total_data_received, + :last_read_time, :last_write_time - @socket = UDPSocket.new - @socket.bind(@hostname, @port) + def initialize( + hostname: CyberarmEngine::Networking::DEFAULT_SERVER_HOSTNAME, + port: CyberarmEngine::Networking::DEFAULT_SERVER_PORT, + max_clients: CyberarmEngine::Networking::DEFAULT_PEER_LIMIT, + channels: 3 + ) + @hostname = hostname + @port = port + @max_clients = max_clients - @channels = Array(0..channels).map { |id| Channel.new(id: id, server: self) } - @peers = [] + @channels = Array(0..channels).map { |id| Channel.new(id: id, mode: :default) } + @peers = [] + + @last_read_time = Networking.milliseconds + @last_write_time = Networking.milliseconds + + @total_packets_sent = 0 + @total_packets_received = 0 + @total_data_sent = 0 + @total_data_received = 0 + end + + # Helpers # + def connected_clients + @peers.size + end + + def clients + @peers + end + + # Callbacks # + + # Called when client connects + def client_connected(peer:) + end + + # Called when client times out or explicitly disconnects + def client_disconnected(peer:, reason:) + end + + ### REMOVE? ### + # Called when client was not sending heartbeats or regular packets for a + # period of time, but was not logically disconnected and removed, and started + # send packets again. + # + # TLDR: Client was temporarily unreachable but did not timeout. + def client_reconnected(peer:) + end + + # Called when a (logical) packet is received from client + def packet_received(peer:, message:, channel:) + end + + # Functions # + # Bind server + def bind + # TODO: Handle socket errors + @socket = UDPSocket.new + @socket.bind(@hostname, @port) + end + + # Send packet to specified peer + def send_packet(peer:, message:, reliable: false, channel: 0) + if (peer = @peers.get(peer)) + packet = PacketHandler.create_raw_packet(message, reliable, channel) + peer.write_queue << packet + else + # TODO: Handle no such peer error + end + end + + # Send packet to all connected peer + def broadcast_packet(message:, reliable: false, channel: 0) + @peers.each { |peer| send_packet(peer.id, message, reliable, channel) } + end + + # Disconnect peer + def disconnect_client(peer:, reason: "") + if (peer = @peers.get(peer)) + packet = PacketHandler.create_disconnect_packet(peer.id, reason) + peer.write_now!(packet) + @peers.delete(peer) + end + end + + def update + while(read) + end + + # handle write queue + # TODO: handle reliable packets differently + @peers.each do |peer| + if Networking.milliseconds - peer.last_read_time > Protocol::TIMEOUT_PERIOD + message = "ERROR: connection timed out" + + write( + peer: peer, + packet: PacketHandler.create_control_packet( + peer: peer, + control_type: Protocol::CONTROL_DISCONNECT, + message: message + ) + ) + client_disconnected(peer: peer, reason: message) + @peers.delete(peer) + next + end + + while(packet = peer.write_queue.shift) + write(peer: peer, packet: packet) + end + end + end + + # !--- this following functions are meant for internal use only ---! # + + def available_peer_id + peer_ids = @peers.map { ||peer| peer.id } + ids = (1..@max_clients).to_a - peer_ids + + ids.size.positive? ? ids.first : nil + end + + def read + data, addr = @socket.recvfrom_nonblock(Protocol::MAX_PACKET_SIZE) + peer = nil + + if (peer = @peers.find { |pr| pr.hostname == addr[2] && pr.port == addr[1] }) + pkt = PacketHandler.handle(host: self, raw: data, peer: peer) + packet_received(peer: peer, message: pkt.message, channel: 0) if pkt.is_a?(RawPacket) + else + # TODO: Reject packet unless it's a connection request + peer = Peer.new(id: 0, hostname: addr[2], port: addr[1]) + pkt = PacketHandler.handle(host: self, raw: data, peer: peer) + + if pkt && !pkt.is_a?(ControlPacket) && pkt.control_type != Protocol::CONTROL_CONNECT + write( + peer: peer, + packet: PacketHandler.create_control_packet( + peer: peer, + control_type: Protocol::CONTROL_DISCONNECT, + message: "ERROR: client not connected" + ) + ) + end + end + + @total_packets_received += 1 + @total_data_received += data.length + @last_read_time = Networking.milliseconds + + true + rescue IO::WaitReadable + false + end + + def write(peer:, packet:) + raw = packet.encode + @socket.send(raw, 0, peer.hostname, peer.port) + + @total_packets_sent += 1 + @total_data_sent += raw.length + @last_write_time = Networking.milliseconds + + peer.total_packets_sent += 1 + peer.total_data_sent += raw.length + peer.last_write_time = Networking.milliseconds + end + end end - - # Helpers # - def connected_clients - @peers.size - end - - def clients - @peers - end - - # Callbacks # - - # Called when client connects - def client_connected(peer) - end - - # Called when client times out or explicitly disconnects - def client_disconnected(peer, reason) - end - - ### REMOVE? ### - # Called when client was not sending heartbeats or regular packets for a - # period of time, but was not logically disconnected and removed, and started - # send packets again. - # - # TLDR: Client was temporarily unreachable but did not timeout. - def client_reconnected(peer) - end - - # Called when a (logical) packet is received from client - def packet_received(peer, message, channel = 0) - end - - # Functions # - # Send packet to specified peer - def send_packet(peer, message, reliable, channel = 0) - end - - # Send packet to all connected peer - def broadcast_packet(message, reliable, channel = 0) - end - - # Disconnect peer - def disconnect_client(peer, reason = "") - end -end \ No newline at end of file +end diff --git a/new_server_test.rb b/new_server_test.rb new file mode 100644 index 0000000..0e0c6c8 --- /dev/null +++ b/new_server_test.rb @@ -0,0 +1,66 @@ +def require_all(directory) + files = Dir["#{directory}/**/*.rb"].sort! + + begin + failed = [] + first_name_error = nil + + files.each do |file| + begin + require_relative file + rescue NameError => name_error + failed << file + first_name_error ||= name_error + end + end + + if failed.size == files.size + raise first_name_error + else + files = failed + end + end until( failed.empty? ) +end + +require "socket" +require_relative "lib/networking" +require_all "lib/networking/backend" + +server = CyberarmEngine::Networking::Server.new +def server.client_connected(peer:) + puts "Client connected as peer: #{peer.id}" +end + +def server.packet_received(peer:, message:, channel:) + pp "Server received: #{message} [on channel: #{channel} from peer: #{peer&.id}]" +end + +Thread.new do + server.bind + + loop do + server.update + sleep (1000.0 / 60.0) / 10.0 + end +end + +connection = CyberarmEngine::Networking::Connection.new(hostname: "localhost", port: CyberarmEngine::Networking::DEFAULT_SERVER_PORT, channels: 3) +def connection.connected + puts "Connection: Connected!" +end + +def connection.disconnected(reason:) + puts "Connection: disconnected: #{reason}" +end + +def connection.packet_received(message:, channel:) + pp "Connection received: #{message} [on channel: #{channel} from peer: SERVER]" +end +connection.connect(timeout: 1_000) + +loop do + connection.update + sleep (1000.0 / 60.0) / 10.0 +end + +sleep