diff --git a/.formatter.exs b/.formatter.exs index f381a71..3561e81 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -9,9 +9,10 @@ inputs = locals_without_parens = [ field: 2, field: 3, - packet: 1, - packet: 2, - packet: 3 + defpacket: 1, + defpacket: 2, + defpacket: 3, + import_packets: 1 ] [ diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 96cf86f..27a9ed5 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -3,10 +3,10 @@ name: Elixir CI on: push: branches: - - master + - main pull_request: branches: - - master + - main permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index 4640904..dcfa649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,5 @@ -# TODO +# Changelog + +## 0.1.0 + +Pre-release, this is not a production ready release ! diff --git a/README.md b/README.md index ab1eace..165d881 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,40 @@ # ElvenGard.Network -[![Build Status](https://github.com/ImNotAVirus/elvengard_network/actions/workflows/elixir.yml/badge.svg?branch=master)](https://github.com/ImNotAVirus/elvengard_network/actions/workflows/elixir.yml) -[![Coverage Status](https://coveralls.io/repos/github/ImNotAVirus/elvengard_network/badge.svg?branch=master)](https://coveralls.io/github/ImNotAVirus/elvengard_network?branch=master) + + +[![Hex.pm version](https://img.shields.io/hexpm/v/elvengard_network.svg?style=flat)](https://hex.pm/packages/elvengard_network) +[![Hex.pm license](https://img.shields.io/hexpm/l/elvengard_network.svg?style=flat)](https://hex.pm/packages/elvengard_network) +[![Build Status](https://github.com/ImNotAVirus/elvengard_network/actions/workflows/elixir.yml/badge.svg?branch=main)](https://github.com/ImNotAVirus/elvengard_network/actions/workflows/elixir.yml) +[![Coverage Status](https://coveralls.io/repos/github/ImNotAVirus/elvengard_network/badge.svg?branch=main)](https://coveralls.io/github/ImNotAVirus/elvengard_network?branch=main) ## What is ElvenGard -Currently, all independent developers wishing to create a MMORPG type game have already asked themselves the question of how to make the server part easily while being "solid" (minimum crash, latency, ...). +Currently, all independent developers wishing to create a Multiplayer game have already asked themselves the question of how to make the server part easily while being "solid" (minimum crash, latency, ...). Indeed, for the client part, there are many tools very well designed to realize it (Unity3D, Unreal Engine,...) but for the server part, each game being different there are currently very few solutions to do this work. -This is the goal of this project: make a toolkit to group together different functionalities present in any MMORPG (network part, quests, movements, objects in game, instances, etc...) to prepare bases for the developer so that he doesn't have to dwell on this part which is often tedious. +This is the goal of this ambitious project: make a toolkit to group together different functionalities present in any MMORPG (network part, quests, movements, objects in game, instances, etc...) to prepare bases for the developer so that he doesn't have to dwell on this part which is often tedious. -## Who is this project for ? +## What is ElvenGard.Network -Initially, this project is intended for anyone who wants to create a game without having to re-code the server part from scratch. -It is also intended for people who want to create an emulator for an existing game. For my tests, I use Nostale as an example. So I already have a predefined network protocol and I don't have to code a client. Later, I also plan to test it with World of Warcraft and FlyFF. Since the network protocols of these games are totally different, this will allow me to test the abstraction of the different features of this toolkit. +[ElvenGard.Network](https://github.com/ImNotAVirus/elvengard_network) is a dedicated toolkit designed to streamline and enhance the network aspect of game server development. Built on top of the "ranch" Erlang library, this powerful toolkit provides developers with a robust foundation for creating multiplayer game servers with ease. -## Installation +Key Features: -Currently not [available in Hex](https://hex.pm/docs/publish), you can use it like that: +1. **Specialized Network Handling:** ElvenGard.Network is solely focused on handling the complexities of networking in multiplayer game servers. It offers a high-performance solution optimized for managing player connections, communication, and data exchange. -```elixir -def deps do - [ - {:elvengard_network, github: "imnotavirus/elvengard_network"} - ] -end -``` +2. **Efficient Protocol Management:** The toolkit simplifies the implementation of network protocols, allowing developers to define and manage packet structures efficiently. This streamlined approach ensures smooth and reliable communication between the game client and server. + +3. **Packet Processing Made Easy:** ElvenGard.Network streamlines packet processing, making it straightforward to handle incoming and outgoing data. Developers can effortlessly manage packet reception, interpretation, and response, thereby reducing the burden of low-level network management. + +4. **Custom Serialization/Deserialization:** With support for custom data types, ElvenGard.Network enables seamless serialization and deserialization of data. This feature ensures compatibility and efficient data exchange between different components of the game server. + +5. **Minimized Latency and Crashes:** By leveraging the "ranch" library, ElvenGard.Network benefits from Erlang's reliability and scalability. This helps minimize latency issues and reduces the risk of crashes, providing a stable networking foundation for multiplayer games. + +With ElvenGard.Network handling the intricate network tasks, game developers can focus on creating captivating gameplay, dynamic worlds, and engaging player experiences. By leveraging this specialized toolkit, the journey from conceptualizing a multiplayer game to deploying a robust server becomes significantly smoother and more efficient. + +## Installation -If [available in Hex](https://hex.pm/docs/publish), the package can be installed -by adding `elvengard_network` to your list of dependencies in `mix.exs`: +The package can be installed by adding `elvengard_network` to your list of dependencies in `mix.exs`: ```elixir def deps do @@ -38,18 +44,16 @@ def deps do end ``` -Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) -and published on [HexDocs](https://hexdocs.pm). Once published, the docs can -be found at [https://hexdocs.pm/elvengard_network](https://hexdocs.pm/elvengard_network). +The docs can be found at [https://hexdocs.pm/elvengard_network](https://hexdocs.pm/elvengard_network). -**/!\ This application is currently not production ready !** +**/!\ This toolkit is currently not production ready !** ## Projects using ElvenGard -- [Flügel](https://github.com/ImNotAVirus/Flugel-NostaleEmu): Created by the same developer as ElvenGard, this is a [Nostale](http://nostale.com/) emulator -- [Imanity](https://github.com/ImNotAVirus/Imanity-FlyffEmu): Created by the same developer as ElvenGard, this is a [FlyFF](http://flyff.webzen.com) emulator +- [MinecraftEx](https://github.com/ImNotAVirus/elvengard_network/tree/main/examples/minecraft_ex): Located in the `examples` folder of the repository, this is the beginning of a [Minecraft](https://www.minecraft.net) server emulator +- [AvantHeim](https://github.com/ImNotAVirus/AvantHeim): Created by the same developer as ElvenGard, this is a [NosTale](https://gameforge.com/en-US/play/nostale) server emulator ## Contributing -Currently developing this project, I will often open pull-requests. Any review is welcome. -Also, feel free to fork the repository. +I'm currently developing this project. Any review or PR is welcome. +Also, feel free to fork the repository and contribute. diff --git a/TODOLIST.md b/TODOLIST.md index e6248b2..6f7a525 100644 --- a/TODOLIST.md +++ b/TODOLIST.md @@ -1,7 +1,9 @@ -# TODOLIST +# Todo list -- [ ] Fix Endpoint start_link and child_spec -- [ ] Serializers for `ElvenGard.Network.Socket` -- [ ] Rewrite the doc for `ElvenGard.Network.Socket` -- [ ] Use `c:handle_error/2` in `ElvenGard.Network.Endpoint.Protocol` -- [ ] Check if the lib is working in clustered mode +- Abstract the message handler and provide a generic way to handle all transport (ranch, gen_tcp, gen_udp, ...) +- Refacto ElvenGard.Network.PacketSerializer +- Refacto ElvenGard.Network.Endpoint.Protocol +- Document all `use` according to the bests practices +- Add telemetry +- use @derive for packet serialization/deserialization instead of `@serializable` and `@deserializable` ?? +- mix task `elven_network.new` to create a project structure diff --git a/examples/echo_server/config/config.exs b/examples/echo_server/config/config.exs index 264dd24..e3b4a99 100644 --- a/examples/echo_server/config/config.exs +++ b/examples/echo_server/config/config.exs @@ -16,6 +16,6 @@ config :echo_server, EchoServer.Endpoint.Protocol, # Here, the packet handler is not needed because we bypass the packet # handling by returning `ignore` in `handle_message/2` packet_handler: :unset, - # Here we are not using packet coder/decoder. + # Here we are not using the network encoder/decoder. # We send raw packets - packet_codec: :unset + network_codec: :unset diff --git a/examples/login_server/.formatter.exs b/examples/login_server/.formatter.exs new file mode 100644 index 0000000..37b3bce --- /dev/null +++ b/examples/login_server/.formatter.exs @@ -0,0 +1,5 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], + import_deps: [:elvengard_network] +] diff --git a/examples/login_server/.gitignore b/examples/login_server/.gitignore new file mode 100644 index 0000000..2e39def --- /dev/null +++ b/examples/login_server/.gitignore @@ -0,0 +1,2 @@ +_build/ +deps/ diff --git a/examples/login_server/config/config.exs b/examples/login_server/config/config.exs new file mode 100644 index 0000000..1271607 --- /dev/null +++ b/examples/login_server/config/config.exs @@ -0,0 +1,11 @@ +import Config + +config :login_server, LoginServer.Endpoint, + listener_name: :login_server, + transport: :ranch_tcp, + transport_opts: [ip: "127.0.0.1", port: 3000], + protocol: LoginServer.Endpoint.Protocol + +config :login_server, LoginServer.Endpoint.Protocol, + packet_handler: LoginServer.Endpoint.PacketHandler, + network_codec: LoginServer.Endpoint.NetworkCodec diff --git a/examples/login_server/lib/login_server.ex b/examples/login_server/lib/login_server.ex new file mode 100644 index 0000000..8a1d081 --- /dev/null +++ b/examples/login_server/lib/login_server.ex @@ -0,0 +1,5 @@ +defmodule LoginServer do + @moduledoc """ + Documentation for `LoginServer`. + """ +end diff --git a/examples/login_server/lib/login_server/application.ex b/examples/login_server/lib/login_server/application.ex new file mode 100644 index 0000000..04f644c --- /dev/null +++ b/examples/login_server/lib/login_server/application.ex @@ -0,0 +1,19 @@ +defmodule LoginServer.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + LoginServer.Endpoint + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: LoginServer.Supervisor] + Supervisor.start_link(children, opts) + end +end diff --git a/examples/login_server/lib/login_server/client_packets.ex b/examples/login_server/lib/login_server/client_packets.ex new file mode 100644 index 0000000..2b1f486 --- /dev/null +++ b/examples/login_server/lib/login_server/client_packets.ex @@ -0,0 +1,22 @@ +defmodule LoginServer.ClientPackets do + @moduledoc """ + Documentation for LoginServer.ClientPackets + """ + + use ElvenGard.Network.PacketSerializer + + alias LoginServer.Types.StringType + + ## Ping packet + + @deserializable true + defpacket "PING", as: PingRequest + + ## Login packet + + @deserializable true + defpacket "LOGIN", as: LoginRequest do + field :username, StringType + field :password, StringType + end +end diff --git a/examples/login_server/lib/login_server/endpoint.ex b/examples/login_server/lib/login_server/endpoint.ex new file mode 100644 index 0000000..c1cd441 --- /dev/null +++ b/examples/login_server/lib/login_server/endpoint.ex @@ -0,0 +1,18 @@ +defmodule LoginServer.Endpoint do + @moduledoc """ + Documentation for LoginServer.Endpoint + """ + + use ElvenGard.Network.Endpoint, otp_app: :login_server + + require Logger + + ## Callbacks + + @impl true + def handle_start(config) do + host = get_in(config, [:transport_opts, :socket_opts, :ip]) + port = get_in(config, [:transport_opts, :socket_opts, :port]) + Logger.info("LoginServer started on #{:inet.ntoa(host)}:#{port}") + end +end diff --git a/examples/login_server/lib/login_server/endpoint/network_codec.ex b/examples/login_server/lib/login_server/endpoint/network_codec.ex new file mode 100644 index 0000000..84607b0 --- /dev/null +++ b/examples/login_server/lib/login_server/endpoint/network_codec.ex @@ -0,0 +1,43 @@ +defmodule LoginServer.Endpoint.NetworkCodec do + @moduledoc """ + Documentation for LoginServer.Endpoint.NetworkCodec + """ + + @behaviour ElvenGard.Network.NetworkCodec + + alias LoginServer.ClientPackets + + ## Behaviour impls + + @impl true + def next(raw, _socket) do + case String.split(raw, "\n", parts: 2) do + [packet] -> {packet, ""} + [packet, rest] -> {packet, rest} + end + end + + @impl true + def decode(raw, socket) do + case String.split(raw, " ", parts: 2) do + [packet_id] -> ClientPackets.deserialize(packet_id, "", socket) + [packet_id, params] -> ClientPackets.deserialize(packet_id, params, socket) + end + end + + @impl true + def encode(struct, _socket) when is_struct(struct) do + # %LoginSucceed{world_info: %WorldInfo{host: "127.0.0.1", port: 5000}} + struct + # {"SUCCESS", ["127.0.0.1", ":", "5000"]} + |> struct.__struct__.serialize() + # ["SUCCESS", ["127.0.0.1", ":", "5000"]] + |> Tuple.to_list() + # ["SUCCESS", " ", ["127.0.0.1", ":", "5000"]] + |> Enum.intersperse(" ") + # [["SUCCESS", " ", ["127.0.0.1", ":", "5000"]], "\n"] + |> then(&[&1 | "\n"]) + + # This will be serialized into `SUCCESS 127.0.0.1:5000\n` + end +end diff --git a/examples/login_server/lib/login_server/endpoint/packet_handler.ex b/examples/login_server/lib/login_server/endpoint/packet_handler.ex new file mode 100644 index 0000000..9d8eea7 --- /dev/null +++ b/examples/login_server/lib/login_server/endpoint/packet_handler.ex @@ -0,0 +1,46 @@ +defmodule LoginServer.Endpoint.PacketHandler do + @moduledoc """ + LoginServer.Endpoint.PacketHandler + """ + + @behaviour ElvenGard.Network.PacketHandler + + alias ElvenGard.Network.Socket + + alias LoginServer.ClientPackets.{PingRequest, LoginRequest} + alias LoginServer.PacketViews + + ## Handlers + + @impl true + def handle_packet(%PingRequest{}, socket) do + render = PacketViews.render(:pong_response, %{time: DateTime.utc_now()}) + :ok = Socket.send(socket, render) + {:cont, socket} + end + + def handle_packet(%LoginRequest{username: username, password: password}, socket) do + render = + if auth_using_db(username, password) do + PacketViews.render(:login_succeed, %{world: get_worlds_from_manager()}) + else + PacketViews.render(:login_failed, %{reason: "Bad credentials :/`"}) + end + + :ok = Socket.send(socket, render) + {:halt, socket} + end + + ## Fake functions + + defp auth_using_db(username, password) do + case {username, password} do + {"admin", "password"} -> true + _ -> false + end + end + + defp get_worlds_from_manager() do + %{host: "127.0.0.1", port: 5000} + end +end diff --git a/examples/login_server/lib/login_server/endpoint/protocol.ex b/examples/login_server/lib/login_server/endpoint/protocol.ex new file mode 100644 index 0000000..a1c9356 --- /dev/null +++ b/examples/login_server/lib/login_server/endpoint/protocol.ex @@ -0,0 +1,35 @@ +defmodule LoginServer.Endpoint.Protocol do + @moduledoc """ + Documentation for LoginServer.Endpoint.Protocol + """ + + use ElvenGard.Network.Endpoint.Protocol + + require Logger + + alias ElvenGard.Network.Socket + + ## Callbacks + + @impl true + def handle_init(%Socket{} = socket) do + Logger.info("New connection: #{socket.id}") + + %Socket{transport: transport, transport_pid: transport_pid} = socket + :ok = transport.setopts(transport_pid, packet: :line, reuseaddr: true) + + {:ok, socket} + end + + @impl true + def handle_message(message, %Socket{} = socket) do + Logger.debug("New message from #{socket.id}: #{inspect(message)}") + {:ok, socket} + end + + @impl true + def handle_halt(reason, %Socket{} = socket) do + Logger.info("#{socket.id} is now disconnected (reason: #{inspect(reason)})") + {:ok, socket} + end +end diff --git a/examples/login_server/lib/login_server/packet_views.ex b/examples/login_server/lib/login_server/packet_views.ex new file mode 100644 index 0000000..ebf49da --- /dev/null +++ b/examples/login_server/lib/login_server/packet_views.ex @@ -0,0 +1,23 @@ +defmodule LoginServer.PacketViews do + @moduledoc """ + Documentation for LoginServer.PacketViews + """ + + use ElvenGard.Network.View + + alias LoginServer.ServerPackets.{PongResponse, LoginFailed, LoginSucceed} + alias LoginServer.SubPackets.WorldInfo + + @impl true + def render(:pong_response, %{time: time}) do + %PongResponse{time: time} + end + + def render(:login_failed, %{reason: reason}) do + %LoginFailed{reason: reason} + end + + def render(:login_succeed, %{world: world}) do + %LoginSucceed{world: %WorldInfo{host: world.host, port: world.port}} + end +end diff --git a/examples/login_server/lib/login_server/server_packets.ex b/examples/login_server/lib/login_server/server_packets.ex new file mode 100644 index 0000000..f4060dd --- /dev/null +++ b/examples/login_server/lib/login_server/server_packets.ex @@ -0,0 +1,29 @@ +defmodule LoginServer.ServerPackets do + @moduledoc """ + Documentation for LoginServer.ServerPackets + """ + + use ElvenGard.Network.PacketSerializer + + alias LoginServer.Types.{DateTimeType, StringType} + alias LoginServer.SubPackets.WorldInfo + + ## Ping packet + + @serializable true + defpacket "PONG", as: PongResponse do + field :time, DateTimeType + end + + ## Login packets + + @serializable true + defpacket "FAIL", as: LoginFailed do + field :reason, StringType + end + + @serializable true + defpacket "SUCCESS", as: LoginSucceed do + field :world, WorldInfo, sep: ":" + end +end diff --git a/examples/login_server/lib/login_server/sub_packets/world_info.ex b/examples/login_server/lib/login_server/sub_packets/world_info.ex new file mode 100644 index 0000000..188d86f --- /dev/null +++ b/examples/login_server/lib/login_server/sub_packets/world_info.ex @@ -0,0 +1,32 @@ +defmodule LoginServer.SubPackets.WorldInfo do + @moduledoc """ + Documentation for LoginServer.SubPackets.WorldInfo + """ + + use ElvenGard.Network.Type + + alias __MODULE__ + alias LoginServer.Types.{IntegerType, StringType} + + @enforce_keys [:host, :port] + defstruct [:host, :port] + + @type t :: %WorldInfo{host: StringType.t(), port: IntegerType.t()} + + ## Behaviour impls + + @impl true + def decode(_data, _opts), do: raise("unimplemented") + + @impl true + @spec encode(t(), Keyword.t()) :: binary() + def encode(data, opts) when is_struct(data, WorldInfo) do + separator = Keyword.fetch!(opts, :sep) + + [ + StringType.encode(data.host), + StringType.encode(separator), + IntegerType.encode(data.port) + ] + end +end diff --git a/examples/login_server/lib/login_server/types/date_time_type.ex b/examples/login_server/lib/login_server/types/date_time_type.ex new file mode 100644 index 0000000..d917608 --- /dev/null +++ b/examples/login_server/lib/login_server/types/date_time_type.ex @@ -0,0 +1,20 @@ +defmodule LoginServer.Types.DateTimeType do + @moduledoc """ + Documentation for LoginServer.Types.DateTimeType + """ + + use ElvenGard.Network.Type + + @type t :: DateTime.t() + + ## Behaviour impls + + @impl true + def decode(_data, _opts), do: raise("unimplemented") + + @impl true + @spec encode(t(), Keyword.t()) :: binary() + def encode(data, _opts) when is_struct(data, DateTime) do + DateTime.to_string(data) + end +end diff --git a/examples/login_server/lib/login_server/types/integer_type.ex b/examples/login_server/lib/login_server/types/integer_type.ex new file mode 100644 index 0000000..98051bd --- /dev/null +++ b/examples/login_server/lib/login_server/types/integer_type.ex @@ -0,0 +1,20 @@ +defmodule LoginServer.Types.IntegerType do + @moduledoc """ + Documentation for LoginServer.Types.IntegerType + """ + + use ElvenGard.Network.Type + + @type t :: integer() + + ## Behaviour impls + + @impl true + def decode(_data, _opts), do: raise("unimplemented") + + @impl true + @spec encode(t(), Keyword.t()) :: binary() + def encode(data, _opts) when is_integer(data) do + Integer.to_string(data) + end +end diff --git a/examples/login_server/lib/login_server/types/string_type.ex b/examples/login_server/lib/login_server/types/string_type.ex new file mode 100644 index 0000000..b8037bc --- /dev/null +++ b/examples/login_server/lib/login_server/types/string_type.ex @@ -0,0 +1,26 @@ +defmodule LoginServer.Types.StringType do + @moduledoc """ + Documentation for LoginServer.Types.StringType + """ + + use ElvenGard.Network.Type + + @type t :: String.t() + + ## Behaviour impls + + @impl true + @spec decode(binary(), Keyword.t()) :: {t(), binary()} + def decode(data, _opts) when is_binary(data) do + case String.split(data, " ", parts: 2) do + [string] -> {string, ""} + [string, rest] -> {string, rest} + end + end + + @impl true + @spec encode(t(), Keyword.t()) :: binary() + def encode(data, _opts) when is_binary(data) do + data + end +end diff --git a/examples/login_server/mix.exs b/examples/login_server/mix.exs new file mode 100644 index 0000000..d594027 --- /dev/null +++ b/examples/login_server/mix.exs @@ -0,0 +1,28 @@ +defmodule LoginServer.MixProject do + use Mix.Project + + def project do + [ + app: :login_server, + version: "0.1.0", + elixir: "~> 1.13", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger], + mod: {LoginServer.Application, []} + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:elvengard_network, path: "../.."} + ] + end +end diff --git a/examples/login_server/mix.lock b/examples/login_server/mix.lock new file mode 100644 index 0000000..093d206 --- /dev/null +++ b/examples/login_server/mix.lock @@ -0,0 +1,3 @@ +%{ + "ranch": {:hex, :ranch, "2.1.0", "2261f9ed9574dcfcc444106b9f6da155e6e540b2f82ba3d42b339b93673b72a3", [:make, :rebar3], [], "hexpm", "244ee3fa2a6175270d8e1fc59024fd9dbc76294a321057de8f803b1479e76916"}, +} diff --git a/examples/login_server/test/test_helper.exs b/examples/login_server/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/examples/login_server/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/examples/minecraft_ex/config/config.exs b/examples/minecraft_ex/config/config.exs index 65fb598..c8805e7 100644 --- a/examples/minecraft_ex/config/config.exs +++ b/examples/minecraft_ex/config/config.exs @@ -13,5 +13,5 @@ config :minecraft_ex, MinecraftEx.Endpoint, protocol: MinecraftEx.Endpoint.Protocol config :minecraft_ex, MinecraftEx.Endpoint.Protocol, - packet_handler: MinecraftEx.Endpoint.PacketHandlers, - packet_codec: MinecraftEx.Endpoint.PacketCodec + packet_handler: MinecraftEx.Endpoint.PacketHandler, + network_codec: MinecraftEx.Endpoint.NetworkCodec diff --git a/examples/minecraft_ex/lib/minecraft_ex.ex b/examples/minecraft_ex/lib/minecraft_ex.ex index 5072ab8..2abe38f 100644 --- a/examples/minecraft_ex/lib/minecraft_ex.ex +++ b/examples/minecraft_ex/lib/minecraft_ex.ex @@ -2,4 +2,6 @@ defmodule MinecraftEx do @moduledoc """ Documentation for `MinecraftEx`. """ + + defguard has_state(socket, state) when socket.assigns.state == state end diff --git a/examples/minecraft_ex/lib/minecraft_ex/client_packets.ex b/examples/minecraft_ex/lib/minecraft_ex/client_packets.ex new file mode 100644 index 0000000..e6dc5a3 --- /dev/null +++ b/examples/minecraft_ex/lib/minecraft_ex/client_packets.ex @@ -0,0 +1,10 @@ +defmodule MinecraftEx.ClientPackets do + @moduledoc """ + TODO: MinecraftEx.ClientPackets + """ + + import ElvenGard.Network.PacketSerializer, only: [import_packets: 1] + + import_packets MinecraftEx.Client.HandshakePackets + import_packets MinecraftEx.Client.LoginPackets +end diff --git a/examples/minecraft_ex/lib/minecraft_ex/client_packets/handshake_packets.ex b/examples/minecraft_ex/lib/minecraft_ex/client_packets/handshake_packets.ex new file mode 100644 index 0000000..9c6f564 --- /dev/null +++ b/examples/minecraft_ex/lib/minecraft_ex/client_packets/handshake_packets.ex @@ -0,0 +1,38 @@ +defmodule MinecraftEx.Client.HandshakePackets do + @moduledoc """ + Documentation for MinecraftEx.Client.HandshakePackets + """ + + use ElvenGard.Network.PacketSerializer + + import MinecraftEx, only: [has_state: 2] + + alias MinecraftEx.Types.{ + Enum, + Long, + MCString, + Short, + VarInt + } + + ## Handshake packets + + # 0x00 Handshake - state=init + @deserializable true + defpacket 0x00 when has_state(socket, :init), as: Handshake do + field :protocol_version, VarInt + field :server_address, MCString + field :server_port, Short, sign: :unsigned + field :next_state, Enum, from: VarInt, values: [status: 1, login: 2] + end + + # 0x00 Status Request - state=status + @deserializable true + defpacket 0x00 when has_state(socket, :status), as: StatusRequest + + # 0x01 Ping Request - state=status + @deserializable true + defpacket 0x01 when has_state(socket, :status), as: PingRequest do + field :payload, Long, sign: :signed + end +end diff --git a/examples/minecraft_ex/lib/minecraft_ex/client_packets/login_packets.ex b/examples/minecraft_ex/lib/minecraft_ex/client_packets/login_packets.ex new file mode 100644 index 0000000..b4d0a19 --- /dev/null +++ b/examples/minecraft_ex/lib/minecraft_ex/client_packets/login_packets.ex @@ -0,0 +1,21 @@ +defmodule MinecraftEx.Client.LoginPackets do + @moduledoc """ + Documentation for MinecraftEx.Client.LoginPackets + """ + + use ElvenGard.Network.PacketSerializer + + import MinecraftEx, only: [has_state: 2] + + alias MinecraftEx.Types.{Boolean, MCString, UUID} + + ## Login packets + + # 0x00 Login Start - state=login + @deserializable true + defpacket 0x00 when has_state(socket, :login), as: LoginStart do + field :name, MCString + field :player_uuid?, Boolean + field :player_uuid, UUID, if: packet.player_uuid? + end +end diff --git a/examples/minecraft_ex/lib/minecraft_ex/endpoint/network_codec.ex b/examples/minecraft_ex/lib/minecraft_ex/endpoint/network_codec.ex new file mode 100644 index 0000000..8f71c62 --- /dev/null +++ b/examples/minecraft_ex/lib/minecraft_ex/endpoint/network_codec.ex @@ -0,0 +1,50 @@ +defmodule MinecraftEx.Endpoint.NetworkCodec do + @moduledoc """ + Documentation for MinecraftEx.Endpoint.NetworkCodec + """ + + @behaviour ElvenGard.Network.NetworkCodec + + alias MinecraftEx.Types.VarInt + alias MinecraftEx.ClientPackets + + @impl true + def next(<<>>, _socket), do: {nil, <<>>} + + def next(message, _socket) do + {length, rest} = VarInt.decode(message) + + case byte_size(rest) >= length do + true -> + <> = rest + {raw, rest} + + false -> + {nil, message} + end + end + + @impl true + def decode(raw, socket) do + {packet_id, rest} = VarInt.decode(raw) + packet = ClientPackets.deserialize(packet_id, rest, socket) + + if is_nil(packet) do + raise "unable to deserialize packet with id #{inspect(packet_id)} - #{inspect(raw)}" + end + + packet + end + + @impl true + def encode(struct, socket) when is_struct(struct) do + {packet_id, params} = struct.__struct__.serialize(struct) + encode([VarInt.encode(packet_id), params], socket) + end + + def encode(raw, _socket) when is_list(raw) do + bin = :binary.list_to_bin(raw) + packet_length = bin |> byte_size() |> VarInt.encode([]) + [<> | bin] + end +end diff --git a/examples/minecraft_ex/lib/minecraft_ex/endpoint/packet_codec.ex b/examples/minecraft_ex/lib/minecraft_ex/endpoint/packet_codec.ex deleted file mode 100644 index cec5e37..0000000 --- a/examples/minecraft_ex/lib/minecraft_ex/endpoint/packet_codec.ex +++ /dev/null @@ -1,46 +0,0 @@ -defmodule MinecraftEx.Endpoint.PacketCodec do - @moduledoc """ - Documentation for MinecraftEx.Endpoint.PacketCodec - """ - - @behaviour ElvenGard.Network.Endpoint.PacketCodec - - alias MinecraftEx.Types.VarInt - alias MinecraftEx.Endpoint.PacketSchemas - - @impl true - def next(<<>>), do: {nil, <<>>} - - def next(message) do - {length, rest} = VarInt.decode(message) - - case byte_size(rest) >= length do - true -> - <> = rest - {raw, rest} - - false -> - {nil, message} - end - end - - @impl true - def deserialize(raw, socket) do - {packet_id, rest} = VarInt.decode(raw) - packet = PacketSchemas.decode(packet_id, rest, socket) - - if is_nil(packet) do - raise "unable to decode packet with id #{inspect(packet_id)} - #{inspect(raw)}" - end - - packet - end - - @impl true - def serialize(raw, _socket) do - packet_length = - raw |> List.wrap() |> Enum.map(&byte_size/1) |> Enum.sum() |> VarInt.encode([]) - - [<> | raw] - end -end diff --git a/examples/minecraft_ex/lib/minecraft_ex/endpoint/packet_handlers.ex b/examples/minecraft_ex/lib/minecraft_ex/endpoint/packet_handler.ex similarity index 88% rename from examples/minecraft_ex/lib/minecraft_ex/endpoint/packet_handlers.ex rename to examples/minecraft_ex/lib/minecraft_ex/endpoint/packet_handler.ex index d505aa6..c856929 100644 --- a/examples/minecraft_ex/lib/minecraft_ex/endpoint/packet_handlers.ex +++ b/examples/minecraft_ex/lib/minecraft_ex/endpoint/packet_handler.ex @@ -1,6 +1,6 @@ -defmodule MinecraftEx.Endpoint.PacketHandlers do +defmodule MinecraftEx.Endpoint.PacketHandler do @moduledoc """ - Documentation for MinecraftEx.Endpoint.PacketHandlers + Documentation for MinecraftEx.Endpoint.PacketHandler """ import ElvenGard.Network.Socket, only: [assign: 3] @@ -8,13 +8,13 @@ defmodule MinecraftEx.Endpoint.PacketHandlers do alias ElvenGard.Network.Socket alias MinecraftEx.Resources - alias MinecraftEx.Endpoint.PacketSchemas.{ + alias MinecraftEx.Client.HandshakePackets.{ Handshake, PingRequest, StatusRequest } - alias MinecraftEx.Endpoint.PacketViews + alias MinecraftEx.PacketViews def handle_packet(%Handshake{} = packet, socket) do {:cont, assign(socket, :state, packet.next_state)} diff --git a/examples/minecraft_ex/lib/minecraft_ex/endpoint/packet_schemas.ex b/examples/minecraft_ex/lib/minecraft_ex/endpoint/packet_schemas.ex deleted file mode 100644 index c3a0dfc..0000000 --- a/examples/minecraft_ex/lib/minecraft_ex/endpoint/packet_schemas.ex +++ /dev/null @@ -1,46 +0,0 @@ -defmodule MinecraftEx.Endpoint.PacketSchemas do - @moduledoc """ - Documentation for MinecraftEx.Endpoint.PacketSchemas - """ - - use ElvenGard.Network.PacketSchema - - alias MinecraftEx.Types.{ - Boolean, - Enum, - Long, - MCString, - Short, - UUID, - VarInt - } - - defguardp has_state(socket, state) when socket.assigns.state == state - - ## Handshake packets - - # 0x00 Handshake - state=init - packet 0x00 when has_state(socket, :init), as: Handshake do - field :protocol_version, VarInt - field :server_address, MCString - field :server_port, Short, sign: :unsigned - field :next_state, Enum, from: VarInt, values: [status: 1, login: 2] - end - - # 0x00 Status Request - state=status - packet 0x00 when has_state(socket, :status), as: StatusRequest - - # 0x01 Ping Request - state=status - packet 0x01 when has_state(socket, :status), as: PingRequest do - field :payload, Long, sign: :signed - end - - ## Login packets - - # 0x00 Login Start - state=login - packet 0x00 when has_state(socket, :login), as: LoginStart do - field :name, MCString - field :player_uuid?, Boolean - field :player_uuid, UUID, if: packet.player_uuid? - end -end diff --git a/examples/minecraft_ex/lib/minecraft_ex/endpoint/packet_views.ex b/examples/minecraft_ex/lib/minecraft_ex/endpoint/packet_views.ex deleted file mode 100644 index 761f3f8..0000000 --- a/examples/minecraft_ex/lib/minecraft_ex/endpoint/packet_views.ex +++ /dev/null @@ -1,23 +0,0 @@ -defmodule MinecraftEx.Endpoint.PacketViews do - @moduledoc """ - Documentation for MinecraftEx.Endpoint.PacketViews - """ - - use ElvenGard.Network.View - - alias MinecraftEx.Types.{Long, MCString} - - @impl true - def render(:status_response, %{status: status}) do - json = Poison.encode!(status) - packet(0x00, [MCString.encode(json)]) - end - - def render(:pong_response, %{payload: payload}) do - packet(0x01, [Long.encode(payload)]) - end - - ## Helpers - - defp packet(id, params), do: [<> | params] -end diff --git a/examples/minecraft_ex/lib/minecraft_ex/packet_views.ex b/examples/minecraft_ex/lib/minecraft_ex/packet_views.ex new file mode 100644 index 0000000..f89bbcc --- /dev/null +++ b/examples/minecraft_ex/lib/minecraft_ex/packet_views.ex @@ -0,0 +1,18 @@ +defmodule MinecraftEx.PacketViews do + @moduledoc """ + Documentation for MinecraftEx.PacketViews + """ + + use ElvenGard.Network.View + + alias MinecraftEx.Server.HandshakePackets.{PongResponse, StatusResponse} + + @impl true + def render(:status_response, %{status: status}) do + %StatusResponse{json: Poison.encode!(status)} + end + + def render(:pong_response, %{payload: payload}) do + %PongResponse{payload: payload} + end +end diff --git a/examples/minecraft_ex/lib/minecraft_ex/server_packets/handshake_packets.ex b/examples/minecraft_ex/lib/minecraft_ex/server_packets/handshake_packets.ex new file mode 100644 index 0000000..e373576 --- /dev/null +++ b/examples/minecraft_ex/lib/minecraft_ex/server_packets/handshake_packets.ex @@ -0,0 +1,23 @@ +defmodule MinecraftEx.Server.HandshakePackets do + @moduledoc """ + Documentation for MinecraftEx.Server.HandshakePackets + """ + + use ElvenGard.Network.PacketSerializer + + alias MinecraftEx.Types.{Long, MCString} + + ## Handshake packets + + # 0x00 Handshake + @serializable true + defpacket 0x00, as: StatusResponse do + field :json, MCString + end + + # 0x00 Handshake + @serializable true + defpacket 0x01, as: PongResponse do + field :payload, Long + end +end diff --git a/examples/minecraft_ex/lib/minecraft_ex/types/enum.ex b/examples/minecraft_ex/lib/minecraft_ex/types/enum.ex index bbcfc4f..cbd0b70 100644 --- a/examples/minecraft_ex/lib/minecraft_ex/types/enum.ex +++ b/examples/minecraft_ex/lib/minecraft_ex/types/enum.ex @@ -6,7 +6,7 @@ defmodule MinecraftEx.Types.Enum do The list of possible values and how each is encoded as an X must be known from the context. An invalid value sent by either side will usually result - in the client being disconnected with an error or even crashing. + in the client being disconnected with an error or even crashing. """ use ElvenGard.Network.Type @@ -22,7 +22,7 @@ defmodule MinecraftEx.Types.Enum do {from, opts} = Keyword.pop!(opts, :from) {enumerators, opts} = Keyword.pop!(opts, :values) - {value, rest} = apply(from, :decode, [data, opts]) + {value, rest} = from.decode(data, opts) {key, _v} = Enum.find(enumerators, &(elem(&1, 1) == value)) {key, rest} diff --git a/examples/minecraft_ex/mix.exs b/examples/minecraft_ex/mix.exs index 6305cf6..d88a13c 100644 --- a/examples/minecraft_ex/mix.exs +++ b/examples/minecraft_ex/mix.exs @@ -5,7 +5,7 @@ defmodule MinecraftEx.MixProject do [ app: :minecraft_ex, version: "0.1.0", - elixir: "~> 1.14", + elixir: "~> 1.13", start_permanent: Mix.env() == :prod, deps: deps() ] diff --git a/guides/introduction/endpoint.md b/guides/introduction/endpoint.md new file mode 100644 index 0000000..42cd278 --- /dev/null +++ b/guides/introduction/endpoint.md @@ -0,0 +1,87 @@ +# Endpoint + +The first thing to do is define a module using `ElvenGard.Network.Endpoint`. + +An Endpoint is simply a [Ranch listener](https://ninenines.eu/docs/en/ranch/2.1/guide/listeners/). + +## Configuration + +Configuration is performed via the `config/config.exs` file as you did previously. + +```elixir +config :login_server, LoginServer.Endpoint, + listener_name: :login_server, + transport: :ranch_tcp, + transport_opts: [ip: "127.0.0.1", port: 3000], + protocol: LoginServer.Endpoint.Protocol +``` + +Here's what each configuration option does: + + - `listener_name: :login_server`: specifies a unique name for the listener. This is used by Ranch + to manage the listener process. + - `transport: :ranch_tcp`: specifies the transport protocol to use. In this case, it's Ranch's TCP + transport. + - `transport_opts: [ip: "127.0.0.1", port: 3000]`: provides options for configuring the transport. + In this case, it specifies the IP address and port on which the server will listen for incoming + connections. + - `protocol: LoginServer.Endpoint.Protocol`: sets the protocol module that will handle client + connections and communication. + +## Creating an Endpoint + +Here's a basic example you can use for all your projects: + +```elixir +# file: lib/login_server/endpoint.ex +defmodule LoginServer.Endpoint do + @moduledoc """ + Documentation for LoginServer.Endpoint + """ + + use ElvenGard.Network.Endpoint, otp_app: :login_server + + require Logger + + ## Callbacks + + @impl true + def handle_start(config) do + host = get_in(config, [:transport_opts, :socket_opts, :ip]) + port = get_in(config, [:transport_opts, :socket_opts, :port]) + Logger.info("LoginServer started on #{:inet.ntoa(host)}:#{port}") + end +end +``` + +As you can see, creating an endpoint is very simple, you just need to specify the +otp app you used in the config and you're done. + +It is also possible to define the `c:ElvenGard.Network.Endpoint.handle_start/1` +callback. This allows you to, for example, display various information relating +to the startup. It receives the endpoint configs as a parameter and must always +return `:ok`. + +## Add to supervision tree + +Once you've created and configured your endpoint, all you have to do is place it +in your application's supervision tree. + +```elixir +# file: lib/login_server/application.ex + + ... + + def start(_type, _args) do + children = [ + LoginServer.Endpoint + ] + + ... +``` + +## Summary + +At the end of this part, you should have a working Endpoint listening on port 3000 +(see config file). +Now it's time to create a Protocol to receive our first packets. diff --git a/guides/introduction/getting_started.md b/guides/introduction/getting_started.md new file mode 100644 index 0000000..567de1a --- /dev/null +++ b/guides/introduction/getting_started.md @@ -0,0 +1,57 @@ +# Getting Started + +This guide is an introduction to [ElvenGard.Network](https://github.com/ImNotAVirus/elvengard_network), the Network toolkit for a MMO Game Server toolkit written in Elixir. +The purpose of this toolkit is to provide a set of modules and processes to simplify the creation of a game server. It's therefore simple to use and flexible in order to allow you to concentrate on the implementation of your features. + +In this guide, we're going to learn some basics about ElvenGard.Network. If you want +to see the code from this guide, you can view it at [elvengard_network/examples/login_server on GitHub](https://github.com/ImNotAVirus/elvengard_network/tree/main/examples/login_server). + +## Adding ElvenGard.Network to an application + +To start off with, we'll generate a new Elixir application by running this command: + +``` +mix new login_server --sup +``` + +The `--sup` option ensures that this application has [a supervision tree](http://elixir-lang.org/getting-started/mix-otp/supervisor-and-application.html), which we'll need for ElvenGard a little later on. + +To add ElvenGard.Network to this application, just add an entry to your mix.exs: + +```elixir +defp deps do + [ + {:elvengard_network, "~> 0.1.0"} + ] +end +``` + +To install this dependency, we will run this command: + +``` +mix deps.get +``` + +Then, we need to add a bit of configuration in `config/config.exs` + +```elixir +import Config + +config :login_server, LoginServer.Endpoint, + listener_name: :login_server, + transport: :ranch_tcp, + transport_opts: [ip: "127.0.0.1", port: 3000], + protocol: LoginServer.Endpoint.Protocol + +config :login_server, LoginServer.Endpoint.Protocol, + packet_handler: LoginServer.Endpoint.PacketHandler, + network_codec: LoginServer.Endpoint.NetworkCodec +``` + +This configuration example simply tells our endpoint to listen on port 3000 of the local address and defines the protocol, network encoder and packet handler to be used. + +For more details on the configuration of each module, please refer to [Endpoint](endpoint.html#configuration) and [Protocol](protocol.html#configuration) guides and [Ranch documentation](https://ninenines.eu/docs/en/ranch/2.1/guide/). + +## Summary + +At the end of this section, you must have setup your app to use Elvengard.Network. Now that we have everything installed, let's create our first Endpoint and get up and running. diff --git a/guides/introduction/network_codec.md b/guides/introduction/network_codec.md new file mode 100644 index 0000000..9b6c651 --- /dev/null +++ b/guides/introduction/network_codec.md @@ -0,0 +1,78 @@ +# Network Codec + +In this section, we will learn how to use `ElvenGard.Network.NetworkCodec`. + +A NetworkCodec is a behaviour that define how a packet should transit over the network. +It must define 3 callbacks : + + - `next/2`: this callback takes a raw binary and a socket as parameters. It returns + the first packet found in the form of a tuple: `{packet_binary, rest_of_binary}`. + - `decode/2`: this callback takes the binary return by `next/2` and returns the + deserialized packet (a structure). You should use the `deserialize/3` helper created + by [defpacket](packet_definitions.html#decorators) + - `encode/2`: this callback will be called when you call `ElvenGard.Network.Socket.send/2` + with your module as encoder. It returns an + [iodata](https://hexdocs.pm/elixir/IO.html#module-io-data) that will, then, be send to + your client. You should use the `serialize/1` helper created by + [defpacket](packet_definitions.html#decorators) + +## Create a Network Codec + + +```elixir +# file: lib/login_server/endpoint/network_codec.ex +defmodule LoginServer.Endpoint.NetworkCodec do + @moduledoc """ + Documentation for LoginServer.Endpoint.NetworkCodec + """ + + @behaviour ElvenGard.Network.NetworkCodec + + alias LoginServer.ClientPackets + + ## Behaviour impls + + @impl true + def next(raw, _socket) do + case String.split(raw, "\n", parts: 2) do + [packet] -> {packet, ""} + [packet, rest] -> {packet, rest} + end + end + + @impl true + def decode(raw, socket) do + case String.split(raw, " ", parts: 2) do + [packet_id] -> ClientPackets.deserialize(packet_id, "", socket) + [packet_id, params] -> ClientPackets.deserialize(packet_id, params, socket) + end + end + + @impl true + def encode(struct, _socket) when is_struct(struct) do + # %LoginSucceed{world_info: %WorldInfo{host: "127.0.0.1", port: 5000}} + struct + # {"SUCCESS", ["127.0.0.1", ":", "5000"]} + |> struct.__struct__.serialize() + # ["SUCCESS", ["127.0.0.1", ":", "5000"]] + |> Tuple.to_list() + # ["SUCCESS", " ", ["127.0.0.1", ":", "5000"]] + |> Enum.intersperse(" ") + # [["SUCCESS", " ", ["127.0.0.1", ":", "5000"]], "\n"] + |> then(&[&1 | "\n"]) + + # This iolist will be send as `SUCCESS 127.0.0.1:5000\n` + end +end +``` + +Here, our `decode/2` callback uses `LoginServer.ClientPackets.deserialize/3`, which +redirects to `LoginServer.ClientPackets..deserialize/3`. +Note also that `encode/2` uses `LoginServer.ServerPackets..serialize/1` +to serialize our packet. Finally, we add a `\n` at the end of our packet as defined by +the [network protocol](network_protocol.html). + +## Summary + +Now that we've defined our functions for transforming data structures, it's time to see how +to link them to our [Protocol](protocol.html). diff --git a/guides/introduction/network_protocol.md b/guides/introduction/network_protocol.md new file mode 100644 index 0000000..9bc64f3 --- /dev/null +++ b/guides/introduction/network_protocol.md @@ -0,0 +1,73 @@ +# Network Protocol + +Before we start coding, we need to define our network protocol, i.e. what our packets +are going to look like, how they are going to be encoded, decoded and so on. + +For this guide, we're going to use a **text-based protocol**, meaning that all commands +exchanged are text that can be read by a human. +We'll be using the `String` module for parsing and tools such as Netcat as a client. + +## Packets + +In this section we'll look at the different packets and interactions between client +and server. + +### Ping + +A client can send a `PingRequest`: + +| Packet ID | Field Name | Field Type | Notes | +|-----------|------------|------------|-------| +| PING | no fields | | | + +When the server receives this packet, it must respond with a `PongResponse`: + +| Packet ID | Field Name | Field Type | Notes | +|-----------|------------|------------|-----------------------------------------------------| +| PONG | time | DateTime | Time at which the server received the ping requests | + +### Login + +A client can send a `LoginRequest`: + +| Packet ID | Field Name | Field Type | Notes | +|-----------|------------|------------|-------| +| LOGIN | username | String | | +| | password | String | | + +Depending on whether the credentials are correct or not, the server can respond in 2 ways, +`LoginFailed`: + +| Packet ID | Field Name | Field Type | Notes | +|-----------|------------|------------|---------------------------------------| +| FAIL | reason | String | The reason why the user cannot log in | + +or `LoginSucceed`: + +| Packet ID | Field Name | Field Type | Notes | +|-----------|------------|------------|----------------------------------| +| SUCCESS | world_info | WorldInfo | Host and port of our game server | + +## Network encoding/decoding + +Now that we know what our packets will look like, we need to define how we're going to +exchange them on the network. + +As Elvengard.Network is currently based on +[Ranch](https://ninenines.eu/docs/en/ranch/2.1/guide/), it only supports the **TCP protocol**. +And, as we use a text protocol, we'll just separate our fields with a **space** and our +different packets with a **line breaks** (`\n`). + +Here's an example of what each packet might look like once encoded: + + - PingRequest: `PING\n` + - PongResponse: `PONG 2023-08-15 20:45:07.068297Z\n` + - LoginRequest: `LOGIN admin password\n` + - LoginFailed: `FAIL Bad credentials\n` + - LoginSucceed: `SUCCESS 127.0.0.1:5000\n` + +## Summary + +Now that you know how your client and server will communicate, you can create and +configure your project. +That's what we'll look at in the next section. diff --git a/guides/introduction/packet_definitions.md b/guides/introduction/packet_definitions.md new file mode 100644 index 0000000..69fcd97 --- /dev/null +++ b/guides/introduction/packet_definitions.md @@ -0,0 +1,192 @@ +# Packet Definitions + +In this section, we will learn how to use `ElvenGard.Network.PacketSerializer`. + +We'll use the previously created types to transform our +[network protocol](network_protocol.html) into Elixir structures. + +## Macros + +### `defpacket` Macro + +First, we'll take a look at how the `ElvenGard.Network.PacketSerializer.defpacket/3` +macro works. + +Here is the most basic example for a packet definition that you can have: + +```elixir +# Textual packet +defpacket "HEADER", as: TextualPacket + +# Binary packet +defpacket 0x0000, as: BinaryPacket +``` + +This will generate a simple structure, without any field. + +The first parameter of the `defpacket` is its ID. It's used to identify it, so that we +know which fields to decode next. + +We also have to specify a `:as` option. This is the name of the generated structure. For +example, the following code will generate a structure called `MyApp.MyPacket` with the +binary packet header `0x0000`. + +```elixir +defmodule MyApp do + defpacket 0x0000, as: MyPacket +end +``` + +#### Guards + +Now, let's imagine you have several packets with the same packet header and to find out +how to deserialize it, you need to access a socket-related state. To do this, you can +use socket assigns and add a guard to the packet: + +```elixir +defpacket 0x0000 when socket.assigns.state == :init, as: InitPacket +defpacket 0x0000 when socket.assigns.state == :sync, as: SyncPacket +``` + +In this example, if when deserializing the packet, the socket state is `:init`, the +structure returned by our deserializer will be `InitPacket` and if it is `:sync`, it +will be `SyncPacket`. + +For more information and examples of how to use guards, I recommend you read the code in +[examples/minecraft_ex](https://github.com/ImNotAVirus/elvengard_network/tree/main/examples/minecraft_ex). + +**NOTE**: guards are only available for deserialization (client packets). + +#### Body + +Finally, to define fields in our packet, their name, type etc., we'll use a `do ... end` block. + +```elixir +defpacket 0x0000, as: MyPacket do + field ... +end +``` + +### `field` Macro + +Now that you know how to create a packet, it's time to see how to create fields. To do this, +you can use the `field` macro. + +It's very easy to use: + +```elixir +field :field1, FieldType +field :field2, FieldType, opt: :value +``` + +Here, the first line will add a field to our packet with `field1` as name and `FieldType` +as type. The name must be an atom and the field type a module which use the +`ElvenGard.Network.Type` behaviour. The third parameter is optional and is a keyword list +defining options to send when types will be decoded (see `c:ElvenGard.Network.Type.encode/2` +and `c:ElvenGard.Network.Type.decode/2`). + +### Decorators + +Using `defpacket` and `field`, you can now create structures with different fields, but +you still can't serialize or deserialize them in order to send them to or receive them +from the client. + +ElvenGard.Network defined 2 decorators for this: + + - `@serializable true`: indicates that the packet is intended to be serialized. It + is therefore a **server packet**. + - `@deserializable true`: indicates that the packet is intended to be deserialized. + It is therefore a **client packet**. + +By tagging a packet as `deserializable`, the `defpacket` macro will automatically create +a `deserialize/1` function inside the generated module. + +Tagging a packet as `deserializable/3` will, this time, create 2 `deserialize/3` functions, +one inside the generated module and one outside it. The latter will redirect to the former. + +These helper functions will come in very handy in our [Network Codec](network_codec.html), +as we'll see in the next chapter. + +Now that we know the theory, it's time to put it into practice. + +## Client Packets + +Let's first define our client packets. + +```elixir +# file: lib/login_server/client_packets.ex +defmodule LoginServer.ClientPackets do + @moduledoc """ + Documentation for LoginServer.ClientPackets + """ + + use ElvenGard.Network.PacketSerializer + + alias LoginServer.Types.StringType + + ## Ping packet + + @deserializable true + defpacket "PING", as: PingRequest + + ## Login packet + + @deserializable true + defpacket "LOGIN", as: LoginRequest do + field :username, StringType + field :password, StringType + end +end +``` + +These packets are pretty straightforward, so there's not much to explain. Just note the +presence of `@deserializable true`, which clearly indicates that these packets are +intended to be deserialized as they are received from the client. + +## Server Packets + +Now let's define the server's one. + +```elixir +# file: lib/login_server/server_packets.ex +defmodule LoginServer.ServerPackets do + @moduledoc """ + Documentation for LoginServer.ServerPackets + """ + + use ElvenGard.Network.PacketSerializer + + alias LoginServer.Types.{DateTimeType, StringType} + alias LoginServer.SubPackets.WorldInfo + + ## Ping packet + + @serializable true + defpacket "PONG", as: PongResponse do + field :time, DateTimeType + end + + ## Login packets + + @serializable true + defpacket "FAIL", as: LoginFailed do + field :reason, StringType + end + + @serializable true + defpacket "SUCCESS", as: LoginSucceed do + field :world, WorldInfo, sep: ":" + end +end +``` + +Once again, these packets are pretty straightforward. We use `@serializable true` to +indicate that we will send them to the client. + +Moreover, we're using the `WorldInfo` sub-packet which, remember, expects the field +separator as an option. This is where we define it. + +## Summary + +You now know how to create a packet that can be serialized and deserialized. Now it's +time to encode or decode it so that it can transit over the network. diff --git a/guides/introduction/packet_handler.md b/guides/introduction/packet_handler.md new file mode 100644 index 0000000..f9e380a --- /dev/null +++ b/guides/introduction/packet_handler.md @@ -0,0 +1,88 @@ +# Packet Handler + +If you remember, in our `config.exs`, we defined the following lines: + +```elixir +config :login_server, LoginServer.Endpoint.Protocol, + packet_handler: LoginServer.Endpoint.PacketHandler, + ... +``` + +Once the packets have been decoded by our Network Codec, they will be redirected to this module. +This module must implement the `ElvenGard.Network.PacketHandler` protocol. + +A Packet Handler is the module that will manage the logic associated with our client packets. + +Let's see how to create one for our demo application. + +## Create a PacketHandler + +```elixir +# file: lib/login_server/endpoint/packet_handler.ex +defmodule LoginServer.Endpoint.PacketHandler do + @moduledoc """ + LoginServer.Endpoint.PacketHandler + """ + + @behaviour ElvenGard.Network.PacketHandler + + alias ElvenGard.Network.Socket + + alias LoginServer.ClientPackets.{PingRequest, LoginRequest} + alias LoginServer.PacketViews + + ## Handlers + + @impl true + def handle_packet(%PingRequest{}, socket) do + render = PacketViews.render(:pong_response, %{time: DateTime.utc_now()}) + :ok = Socket.send(socket, render) + {:cont, socket} + end + + def handle_packet(%LoginRequest{username: username, password: password}, socket) do + render = + if auth_using_db(username, password) do + PacketViews.render(:login_succeed, %{world: get_worlds_from_manager()}) + else + PacketViews.render(:login_failed, %{reason: "Bad credentials :/`"}) + end + + :ok = Socket.send(socket, render) + {:halt, socket} + end + + ## Fake functions + + defp auth_using_db(username, password) do + case {username, password} do + {"admin", "password"} -> true + _ -> false + end + end + + defp get_worlds_from_manager() do + %{host: "127.0.0.1", port: 5000} + end +end +``` + +Note that each handler takes as parameter a structure with decoded fields representing the packet +and the socket associated with it. A handler must return `{:cont, new_socket}` if we want to +continue receiving packets, or `{:halt, new_socket}` to close the connection to the socket +and shutdown the associated GenServer. + +Here, our handlers are quite simple: the first will just send to our client a `PongResponse` packet +with the current time and the second will return a `LoginSucceed` or a `LoginFailed` depending +on the credentials passed in parameter. + +Note also that after a `PingRequest`, we'll continue to handle packets, whereas after a +`LoginRequest` we'll automatically close the connection. + +## Summary + +In this section, we've learned how to handle client packets, create logic around them and use +the previously created [Views](packet_views.html). + +Our application is now ready for use. You can view the whole source code at +[examples/login_server](https://github.com/ImNotAVirus/elvengard_network/tree/main/examples/login_server). diff --git a/guides/introduction/packet_views.md b/guides/introduction/packet_views.md new file mode 100644 index 0000000..4df63d6 --- /dev/null +++ b/guides/introduction/packet_views.md @@ -0,0 +1,52 @@ +# Packet Views + +In this section, we will learn how to use `ElvenGard.Network.View`. + +The role of a View is to prepare data to be sent via a packet structure. + +For example, we may have a packet taking a string as a field, but our backend returns a map. +Encoding our map every time we need to create a packet would be problematic, as it would +create code duplication and would be a potential source of errors. + +Other common uses are, for example, when a packet has one or more sub-packets, or when we +need to set default values for certain fields. For simplicity's sake, and to avoid having to +duplicate this logic in every function that needs to render packets, views have been created. + +## Create a Packet Views + +```elixir +# file: lib/login_server/packet_views.ex +defmodule LoginServer.PacketViews do + @moduledoc """ + Documentation for LoginServer.PacketViews + """ + + use ElvenGard.Network.View + + alias LoginServer.ServerPackets.{PongResponse, LoginFailed, LoginSucceed} + alias LoginServer.SubPackets.WorldInfo + + @impl true + def render(:pong_response, %{time: time}) do + %PongResponse{time: time} + end + + def render(:login_failed, %{reason: reason}) do + %LoginFailed{reason: reason} + end + + def render(:login_succeed, %{world: world}) do + %LoginSucceed{world: %WorldInfo{host: world.host, port: world.port}} + end +end +``` + +Not much to explain, except that the `render/2` callback takes as its first parameter an +identifier (which must be a `String` or an `Atom`) and as its second parameter attributes +enabling us to generate our packet. Most of the time, the callback returns a strucure +(a packet), but in exceptional cases it can also directly return iodata. + +## Summary + +You now know how to create Views: functions for creating server packets. In the final +chapter, we'll use this module to create and send packets to our client. diff --git a/guides/introduction/protocol.md b/guides/introduction/protocol.md new file mode 100644 index 0000000..ae8f486 --- /dev/null +++ b/guides/introduction/protocol.md @@ -0,0 +1,95 @@ +# Protocol + +In this section, we will learn how to use `ElvenGard.Network.Endpoint.Protocol`. + +An Protocol is wrapper around [Ranch protocols](https://ninenines.eu/docs/en/ranch/2.1/guide/protocols/). + +## Configuration + +Configuration is performed via the `config/config.exs` file as you did previously. + +```elixir +config :login_server, LoginServer.Endpoint.Protocol, + packet_handler: LoginServer.Endpoint.PacketHandler, + network_codec: LoginServer.Endpoint.NetworkCodec +``` + +Here's the explanation for these options: + + - `packet_handler: LoginServer.PacketHandler`: specifies the module responsible for + handling packets received from clients. + - `network_codec: LoginServer.NetworkCodec`: Specifies the module responsible for + encoding and decoding packets for communication between clients and the server. + +## Create a Protocol + +For this part, we're going to create a fairly simple protocol that will just display +our packets. + +```elixir +# file: lib/login_server/endpoint/protocol.ex +defmodule LoginServer.Endpoint.Protocol do + @moduledoc """ + Documentation for LoginServer.Endpoint.Protocol + """ + + use ElvenGard.Network.Endpoint.Protocol + + require Logger + + alias ElvenGard.Network.Socket + + ## Callbacks + + @impl true + def handle_init(%Socket{} = socket) do + Logger.info("New connection: #{socket.id}") + + %Socket{transport: transport, transport_pid: transport_pid} = socket + :ok = transport.setopts(transport_pid, packet: :line, reuseaddr: true) + + {:ok, socket} + end + + @impl true + def handle_message(message, %Socket{} = socket) do + Logger.debug("New message from #{socket.id}: #{inspect(message)}") + :ignore + end + + @impl true + def handle_halt(reason, %Socket{} = socket) do + Logger.info("#{socket.id} is now disconnected (reason: #{inspect(reason)})") + {:ok, socket} + end +end +``` + +Once again, creating a Protocol is fairly straightforward. + +This example defines 3 callbacks : + + - `handle_init/1`: called when a client connects, it is mainly used to set + [socket options](https://www.erlang.org/doc/man/inet#setopts-2) or + call `ElvenGard.Network.Socket.assign/2` to init assigns. + - `handle_message/2`: called when we receive a packet from a client, we can + either ignore it by returning `:ignore`, or choose to decode it and then + handle it by returning `:ok`. + - `handle_halt/2`: called when a client disconnects. + +**NOTE**: you may notice that we define the `packet: :line` option in `handle_init/1`. +We use this option because we want to use a line break as a separator for our packets. +This works because, according to our [network protocol](network_protocol.html), we use +a text protocol where each packet is separated by a `\n`. However, for a binary protocol, +you may need to use `packet: :raw` or other options. +For more information on available options, see `:inet.setopts/2`. + +## Summary + +If you run your application in its current state, you'll see that it's possible +to connect to our server and send it messages, and that these are displayed by +our application. +Since we're using a text-based protocol, you can use Netcat as client for example. + +Now that packets can be received, they need to be decoded and processed. +This is what we'll see next. diff --git a/guides/introduction/types_and_subpackets.md b/guides/introduction/types_and_subpackets.md new file mode 100644 index 0000000..5eb6ba9 --- /dev/null +++ b/guides/introduction/types_and_subpackets.md @@ -0,0 +1,170 @@ +# Types and Sub Packets + +In this section, we will learn how to use `ElvenGard.Network.Type`. + +We're going to create all the types and subpackets needed to serialize and deserialize +our packets according to our [network protocol](network_protocol.html). + +## Types + +So, according to our network protocol, we must define theses types: + +| Field Type | Encoded or Decoded | Used by | +|------------|--------------------|---------------------------| +| String | Both | LoginRequest, LoginFailed | +| DateTime | Encoded | PongResponse | + +Let's start with the first one: + +```elixir +# file: lib/login_server/types/string_type.ex +defmodule LoginServer.Types.StringType do + @moduledoc """ + Documentation for LoginServer.Types.StringType + """ + + use ElvenGard.Network.Type + + @type t :: String.t() + + ## Behaviour impls + + @impl true + @spec decode(binary(), Keyword.t()) :: {t(), binary()} + def decode(data, _opts) when is_binary(data) do + case String.split(data, " ", parts: 2) do + [string] -> {string, ""} + [string, rest] -> {string, rest} + end + end + + @impl true + @spec encode(t(), Keyword.t()) :: binary() + def encode(data, _opts) when is_binary(data) do + data + end +end +``` + +As you can see, we just need to use `ElvenGard.Network.Type` and define 2 callbacks: + + - `decode/2`: takes the binary to be decoded and options. This callback must return a + tuple in the form `{type_decoded, rest_of_binary_not_decoded}`. + - `encode/2`: takes the type to encode and options. This callback must return an + [iodata](https://hexdocs.pm/elixir/IO.html#module-io-data) representation of our type. + +**NOTE**: Typespecs and guards are not mandatory, but are a good practice. + +```elixir +# file: lib/login_server/types/date_time_type.ex +defmodule LoginServer.Types.DateTimeType do + @moduledoc """ + Documentation for LoginServer.Types.DateTimeType + """ + + use ElvenGard.Network.Type + + @type t :: DateTime.t() + + ## Behaviour impls + + @impl true + def decode(_data, _opts), do: raise("unimplemented") + + @impl true + @spec encode(t(), Keyword.t()) :: binary() + def encode(data, _opts) when is_struct(data, DateTime) do + DateTime.to_string(data) + end +end +``` + +For this second type it's the same, the only difference is that we don't need to need +to deserialize a `DateTimeType`, so we can just ignore the function and raise if someone +is trying to use it. + +## Sub Packets + +A sub-packet is simply a type that uses other types. Unlike a packet, it has no ID, as it +will be used by other packets. + +Let's look at a simple example with the `WorldInfo`: + +| SubPacket Name | Encoded or Decoded | Used by | +|----------------|--------------------|--------------| +| WorldInfo | Encoded | LoginSucceed | + +```elixir +# file: lib/login_server/sub_packets/world_info.ex +defmodule LoginServer.SubPackets.WorldInfo do + @moduledoc """ + Documentation for LoginServer.SubPackets.WorldInfo + """ + + use ElvenGard.Network.Type + + alias __MODULE__ + alias LoginServer.Types.{IntegerType, StringType} + + @enforce_keys [:host, :port] + defstruct [:host, :port] + + @type t :: %WorldInfo{host: StringType.t(), port: IntegerType.t()} + + ## Behaviour impls + + @impl true + def decode(_data, _opts), do: raise("unimplemented") + + @impl true + @spec encode(t(), Keyword.t()) :: iolist() + def encode(data, opts) when is_struct(data, WorldInfo) do + separator = Keyword.fetch!(opts, :sep) + + [ + StringType.encode(data.host), + StringType.encode(separator), + IntegerType.encode(data.port) + ] + end +end +``` + +As you can see, this type is represented in Elixir by a structure with 2 mandatory fields: +`host` and `port`. They are each represented by the `StringType` and `IntegerType` types, +with which they will be encoded. + +**NOTE**: this sub-packet has another special feature: the separator used by the fields can be +configured through options. We'll see how to use it in the next sections. + +As the `IntegerType` type has not been created yet, let's create it defining only the +`encode/2` function. + +```elixir +# file: lib/login_server/types/integer_type.ex +defmodule LoginServer.Types.IntegerType do + @moduledoc """ + Documentation for LoginServer.Types.IntegerType + """ + + use ElvenGard.Network.Type + + @type t :: integer() + + ## Behaviour impls + + @impl true + def decode(_data, _opts), do: raise("unimplemented") + + @impl true + @spec encode(t(), Keyword.t()) :: binary() + def encode(data, _opts) when is_integer(data) do + Integer.to_string(data) + end +end +``` + +## Summary + +In this guide, we've seen how to create customizable types and sub-packets. Now we'll look at +how to use them. diff --git a/lib/elven_gard/network.ex b/lib/elven_gard/network.ex index a8ea8f0..34dc57a 100644 --- a/lib/elven_gard/network.ex +++ b/lib/elven_gard/network.ex @@ -1,5 +1,8 @@ defmodule ElvenGard.Network do - @moduledoc """ - Documentation for `ElvenGard.Network`. - """ + @external_resource "README.md" + + @moduledoc "README.md" + |> File.read!() + |> String.split("") + |> Enum.fetch!(1) end diff --git a/lib/elven_gard/network/endpoint.ex b/lib/elven_gard/network/endpoint.ex index 76b8b0f..0422236 100644 --- a/lib/elven_gard/network/endpoint.ex +++ b/lib/elven_gard/network/endpoint.ex @@ -1,6 +1,13 @@ defmodule ElvenGard.Network.Endpoint do @moduledoc ~S""" - TODO: Documentation for ElvenGard.Network.Endpoint + Wrapper on top of [Ranch listeners](https://ninenines.eu/docs/en/ranch/2.1/guide/listeners/). + + This module provides a wrapper around the Ranch library to define network + endpoints. Endpoints are crucial for managing incoming connections and + handling network traffic efficiently. + + For in-depth information on how to use and configure network endpoints, please + refer to the [Endpoint documentation](https://hexdocs.pm/elvengard_network/endpoint.html). """ @doc "Called just before starting the ranch listener" @@ -36,10 +43,12 @@ defmodule ElvenGard.Network.Endpoint do end @doc """ - Returns the child specification to start the endpoint - under a supervision tree. + Returns a specification to start this module under a supervisor. + + See `Supervisor`. """ def child_spec(opts) do + # Not sure if the is a better way to do this (call a callback on Endpoint start) if opts[:ignore_init] != true do :ok = handle_start(@config) end @@ -53,21 +62,6 @@ defmodule ElvenGard.Network.Endpoint do ) end - @doc """ - Starts the endpoint. - """ - def start_link(_opts) do - :ok = handle_start(@config) - - :ranch.start_listener( - __listener_name__(), - @config[:transport], - @config[:transport_opts], - @config[:protocol], - @config[:protocol_opts] - ) - end - @doc """ Returns the Endpoint's configuration. """ diff --git a/lib/elven_gard/network/endpoint/packet_codec.ex b/lib/elven_gard/network/endpoint/packet_codec.ex deleted file mode 100644 index 99e43e3..0000000 --- a/lib/elven_gard/network/endpoint/packet_codec.ex +++ /dev/null @@ -1,20 +0,0 @@ -defmodule ElvenGard.Network.Endpoint.PacketCodec do - @moduledoc ~S""" - Define a behaviour for packet encoding and decoding - """ - - alias ElvenGard.Network.Socket - - @doc """ - Returns the first packet found in a raw binary - - The result will be sent to the `c:deserialize/2` callback - """ - @callback next(raw :: bitstring) :: {packet_raw :: bitstring, remaining :: bitstring} - - @doc "Deserializes a packet" - @callback deserialize(raw :: bitstring, socket :: Socket.t()) :: map | struct - - @doc "Serialize a `ElvenGard.Network.View`" - @callback serialize(raw :: iodata, socket :: Socket.t()) :: iodata -end diff --git a/lib/elven_gard/network/endpoint/protocol.ex b/lib/elven_gard/network/endpoint/protocol.ex index 8fe99f2..ce4b60e 100644 --- a/lib/elven_gard/network/endpoint/protocol.ex +++ b/lib/elven_gard/network/endpoint/protocol.ex @@ -1,18 +1,48 @@ defmodule ElvenGard.Network.Endpoint.Protocol do - @moduledoc """ - TODO: Documentation + @moduledoc ~S""" + Wrapper on top of [Ranch protocols](https://ninenines.eu/docs/en/ranch/2.1/guide/protocols/). + + This module defines a protocol behavior to handle incoming connections in the + ElvenGard.Network library. It provides callbacks for initializing, handling + incoming messages, and handling connection termination. + + This protocol behavior serves as a wrapper around Ranch protocols, providing + a structured way to implement connection handling within ElvenGard.Network. + + For detailed information on implementing and using network protocols + with ElvenGard.Network, please refer to the + [Endpoint Protocol guide](https://hexdocs.pm/elvengard_network/protocol.html). """ alias ElvenGard.Network.Socket - @doc "Called just before entering the GenServer loop" + @doc """ + Callback called just before entering the GenServer loop. + + This callback is invoked when a new connection is established and before the + GenServer loop starts processing messages. + + For the return values, see `c:GenServer.init/1` + """ @callback handle_init(socket :: Socket.t()) :: {:ok, new_socket} | {:ok, new_socket, timeout | :hibernate | {:continue, continue_arg}} | {:stop, reason :: term, new_socket} when new_socket: Socket.t(), continue_arg: term - @doc "Called just after receiving a message" + @doc """ + Callback called just after receiving a message. + + This callback is invoked whenever a message is received on the connection. It should + return one of the following: + + - `:ignore`: the message received will not be decoded or processed by the protocol. + It will just be ignored + - `{:ignore, new_socket}`: same as `:ignore` but also modifies the socket + - `{:ok, new_socket}`: classic loop - decode the packet and process it + - `{:stop, reason, new_socket}`: stop the GenServer/Protocol and disconnect the client + + """ @callback handle_message(message :: binary, socket :: Socket.t()) :: :ignore | {:ignore, new_socket} @@ -20,22 +50,19 @@ defmodule ElvenGard.Network.Endpoint.Protocol do | {:stop, reason :: term, new_socket} when new_socket: Socket.t() - @doc "Called after the socket connection is closed and before the GenServer shutdown" + @doc """ + Callback called after the socket connection is closed and before the GenServer + shutdown. + + """ @callback handle_halt(reason :: term, socket :: Socket.t()) :: {:ok, new_socket} | {:ok, stop_reason :: term, new_socket} when new_socket: term - @doc "Called when an error occurs and specifies what to do" - @callback handle_error(reason :: term, socket :: Socket.t()) :: - {:ignore, new_socket} - | {:stop, reason :: term, new_socket} - when new_socket: term - @optional_callbacks handle_init: 1, handle_message: 2, - handle_halt: 2, - handle_error: 2 + handle_halt: 2 ## Public API @@ -115,12 +142,14 @@ defmodule ElvenGard.Network.Endpoint.Protocol do @app Mix.Project.get().project[:app] defp env_config(), do: Application.fetch_env!(@app, __MODULE__) - defp codec(), do: env_config()[:packet_codec] + defp codec(), do: env_config()[:network_codec] defp handlers(), do: env_config()[:packet_handler] + defp packet_loop(<<>>, socket), do: {:noreply, socket} + defp packet_loop(data, socket) do - with {:next, {raw, rest}} when not is_nil(raw) <- {:next, codec().next(data)}, - struct <- codec().deserialize(raw, socket), + with {:next, {raw, rest}} when not is_nil(raw) <- {:next, codec().next(data, socket)}, + struct <- codec().decode(raw, socket), {:handle, {:cont, new_socket}} <- {:handle, handlers().handle_packet(struct, socket)} do packet_loop(rest, new_socket) else @@ -170,13 +199,9 @@ defmodule ElvenGard.Network.Endpoint.Protocol do @impl true def handle_halt(_reason, socket), do: {:ok, socket} - @impl true - def handle_error(reason, socket), do: {:stop, reason, socket} - defoverridable handle_init: 1, handle_message: 2, - handle_halt: 2, - handle_error: 2 + handle_halt: 2 end end end diff --git a/lib/elven_gard/network/exceptions.ex b/lib/elven_gard/network/exceptions.ex index 15068fb..19ccda6 100644 --- a/lib/elven_gard/network/exceptions.ex +++ b/lib/elven_gard/network/exceptions.ex @@ -1,6 +1,6 @@ defmodule ElvenGard.Network.UnknownViewError do @moduledoc ~S""" - Exception raised when a view is not found + Exception raised when a View is not found """ defexception [:parent, :type] diff --git a/lib/elven_gard/network/network_codec.ex b/lib/elven_gard/network/network_codec.ex new file mode 100644 index 0000000..33f6642 --- /dev/null +++ b/lib/elven_gard/network/network_codec.ex @@ -0,0 +1,62 @@ +defmodule ElvenGard.Network.NetworkCodec do + @moduledoc ~S""" + Behavior for Defining Packet Encoding and Decoding + + Implementations of this behavior are responsible for parsing incoming + raw binary data into structured packets and encoding structured packets into + binary data for transmission over the network. + + For more information on how to implement this behavior and use packet encoding + and decoding, please refer to the + [NetworkCodec guide](https://hexdocs.pm/elvengard_network/network_codec.html). + """ + + alias ElvenGard.Network.Socket + + @doc """ + Identifies and extracts the first packet from raw binary data. + + This function searches for the first packet within the raw binary data and + returns it along with the remaining binary data. The extracted packet will + be passed to the `decode/2` callback for further processing. + + ## Examples + + raw = <> + {^packet1, ^remaining} = MyNetworkCodec.next(raw, socket) + + """ + @callback next(raw :: bitstring, socket :: Socket.t()) :: + {packet_raw :: bitstring, remaining :: bitstring} + + @doc """ + Decodes a packet from raw binary data. + + This callback function is responsible for decoding a packet from the provided + raw binary data. The decoded packet should be returned as a struct containing + the parsed fields. + + ## Examples + + raw_packet = <<1::8, 123::8, "some data"::binary>> # Example raw packet data + %MessageStruct{id: 123, data: "some data"} = MyNetworkCodec.decode(raw_packet, socket) + + """ + @callback decode(raw :: bitstring, socket :: Socket.t()) :: struct + + @doc """ + Encodes a packet for network transmission. + + This callback function is responsible for encoding a structured packet or raw + binary data into the binary format suitable for transmission over the network. + The resulting encoded data, in the form of `iodata()`, can be directly sent + over the network. + + ## Examples + + packet = %MessageStruct{id: 1, data: "Hello"} + <> = MyNetworkCodec.encode(packet, socket) + + """ + @callback encode(packet :: struct() | iodata(), socket :: Socket.t()) :: iodata() +end diff --git a/lib/elven_gard/network/packet_handler.ex b/lib/elven_gard/network/packet_handler.ex new file mode 100644 index 0000000..e2e1aa4 --- /dev/null +++ b/lib/elven_gard/network/packet_handler.ex @@ -0,0 +1,31 @@ +defmodule ElvenGard.Network.PacketHandler do + @moduledoc """ + Provides a behavior for handling incoming packets. + + This module defines the callback `handle_packet/2`, which must be implemented by + modules using this behavior to process incoming packets. + + For detailed usage information, please refer to the + [packet handler guide](https://hexdocs.pm/elvengard_network/packet_handler.html). + """ + + alias ElvenGard.Network.Socket + + @doc """ + This callback function is called after a packet is received and decoded by the Endpoint. + + It is responsible for processing the packet data and returning one of the following tuples: + + - `{:cont, socket}`: Indicates that packet processing is complete, and the client connection + should continue. + - `{:halt, socket}`: Indicates that packet processing is complete, and the client connection + should be terminated. + + ## Parameters + + - `packet`: The packet data as a struct. + - `socket`: The socket representing the client connection. + """ + @callback handle_packet(packet :: struct(), socket :: Socket.t()) :: + {:cont, Socket.t()} | {:halt, Socket.t()} +end diff --git a/lib/elven_gard/network/packet_schema.ex b/lib/elven_gard/network/packet_schema.ex deleted file mode 100644 index 3367e42..0000000 --- a/lib/elven_gard/network/packet_schema.ex +++ /dev/null @@ -1,194 +0,0 @@ -defmodule ElvenGard.Network.PacketSchema do - @moduledoc ~S""" - ElvenGard.Network.PacketSchema - """ - - @type packet_id :: integer() | binary() - - ## Helpers - - defguardp is_packet_id(id) when is_integer(id) or is_binary(id) - - ## Public API - - # packet 0x0000 - defmacro packet(id) when is_packet_id(id) do - do_packet(id, id_to_name(id), nil, nil) - end - - # packet 0x0000 when ... - defmacro packet({:when, _, [id, guards]}) when is_packet_id(id) do - do_packet(id, id_to_name(id), guards, nil) - end - - # packet 0x0000, as: ModuleName - defmacro packet(id, as: name) when is_packet_id(id) do - do_packet(id, name, nil, nil) - end - - # packet 0x0000 when ..., as: ModuleName - defmacro packet({:when, _, [id, guards]}, as: name) when is_packet_id(id) do - do_packet(id, name, guards, nil) - end - - # packet 0x0000 do ... end - defmacro packet(id, do: exp) when is_packet_id(id) do - do_packet(id, id_to_name(id), nil, exp) - end - - # packet 0x0000 when ... do ... end - defmacro packet({:when, _, [id, guards]}, do: exp) when is_packet_id(id) do - do_packet(id, id_to_name(id), guards, exp) - end - - # packet 0x0000, as: ModuleName do ... end - defmacro packet(id, [as: name], do: exp) when is_packet_id(id) do - do_packet(id, name, nil, exp) - end - - # packet 0x0000 when ..., as: ModuleName do ... end - defmacro packet({:when, _, [id, guards]}, [as: name], do: exp) when is_packet_id(id) do - do_packet(id, name, guards, exp) - end - - # field :protocol_version, VarInt - defmacro field(name, type, opts \\ []) do - do_field(name, type, opts) - end - - defmacro __using__(_env) do - quote location: :keep do - import unquote(__MODULE__), - only: [ - packet: 1, - packet: 2, - packet: 3, - field: 2, - field: 3 - ] - - @before_compile unquote(__MODULE__) - - Module.register_attribute(__MODULE__, :egn_packet_fields, accumulate: true) - Module.register_attribute(__MODULE__, :egn_packets, accumulate: true) - end - end - - defmacro __before_compile__(env) do - egn_packets = Module.get_attribute(env.module, :egn_packets) - - egn_packets - |> Enum.map(fn %{id: id, name: name, fields: fields, guards: guards} = packet -> - quote location: :keep, generated: true do - defmodule :"#{Module.concat(__MODULE__, unquote(name))}" do - # Structure - - defstruct Enum.map(unquote(Macro.escape(fields)), & &1.name) - - # Introspection - - @doc false - def __schema__(:id), do: unquote(id) - def __schema__(:name), do: unquote(name) - def __schema__(:guards), do: unquote(Macro.escape(guards)) - def __schema__(:fields), do: unquote(Macro.escape(fields)) - end - - # Decode term to struct - - unquote(decode_ast(packet)) - end - end) - |> Kernel.++([schemas_introspection()]) - end - - ## Private funtions - - defp id_to_name(id) when is_binary(id), do: :"Elixir.#{Macro.camelize(id)}" - - defp id_to_name(id) when is_integer(id) do - raise "a module name is required for integer packet ids" - end - - defp do_packet(id, name, guards, exp) do - quote location: :keep do - Module.delete_attribute(__MODULE__, :egn_packet_fields) - - unquote(exp) - - @egn_packets %{ - id: unquote(id), - name: unquote(name), - guards: unquote(Macro.escape(guards)), - fields: Enum.reverse(@egn_packet_fields) - } - end - end - - defp do_field(name, type, opts) do - quote location: :keep do - @egn_packet_fields %{ - name: unquote(name), - type: unquote(type), - opts: unquote(Macro.escape(opts)) - } - end - end - - defp schemas_introspection() do - quote do - @doc false - def __schemas__(), do: @egn_packets - end - end - - defp decode_ast(packet) do - %{id: id, name: name, fields: fields, guards: guards} = packet - guards = if is_nil(guards), do: true, else: guards - fields_ast = fields |> Enum.map(&field_ast(&1, &1[:opts][:if])) |> merge_ast_blocks() - - quote location: :keep, generated: true do - def decode(var!(packet_id) = unquote(id), var!(data), var!(socket)) when unquote(guards) do - var!(packet) = struct(Module.concat(__MODULE__, unquote(name)), packet_id: unquote(id)) - - unquote(fields_ast) - - if var!(data) != "" do - iname = inspect(unquote(name)) - raise "remaining bytes for #{inspect(__MODULE__)}.#{iname}: #{inspect(var!(data))}" - end - - var!(packet) - end - end - end - - defp field_ast(%{name: name, type: type, opts: opts}, nil) do - quote location: :keep, generated: true do - {value, var!(data)} = unquote(type).decode(var!(data), unquote(opts)) - var!(packet) = Map.put(var!(packet), unquote(name), value) - end - end - - defp field_ast(%{name: name, type: type, opts: opts}, condition) do - quote location: :keep, generated: true do - {value, var!(data)} = - case unquote(condition) do - result when result in [false, nil] -> {nil, var!(data)} - _ -> unquote(type).decode(var!(data), unquote(opts)) - end - - var!(packet) = Map.put(var!(packet), unquote(name), value) - end - end - - defp merge_ast_blocks(blocks) do - blocks - |> Macro.prewalk(fn - {:__block__, _, block} -> block - ast -> ast - end) - |> List.flatten() - |> then(&{:__block__, [], &1}) - end -end diff --git a/lib/elven_gard/network/packet_serializer.ex b/lib/elven_gard/network/packet_serializer.ex new file mode 100644 index 0000000..1b2a675 --- /dev/null +++ b/lib/elven_gard/network/packet_serializer.ex @@ -0,0 +1,331 @@ +defmodule ElvenGard.Network.PacketSerializer do + @moduledoc ~S""" + Packet Serializer DSL for defining packet structures. + + This module provides a DSL (Domain-Specific Language) for defining packet serializers + for both received and sent packets in a network protocol. It enables users to create + structured packets with specified fields and decode binary data into packet structs. + + To learn more about the available macros and how to define packet serializers, refer + to the [Packet Serializer DSL guide](https://hexdocs.pm/elvengard_network/packet_definitions.html). + + ## Packet Macros + + The `defpacket` macros (`defpacket/1`, `defpacket/2`, and `defpacket/3`) allow users + to define packet structures. They require a packet ID and an alias (which is the name + of the generated packet structure). The guard (with the `when` keyword) and do-block + (for defining fields) are optional. + + Users can specify guards in packet macros to conditionally match packets based on a + condition (often using socket assigns). + + ## Packet Structure, Serialization, and Deserialization + + The `defpacket` macros can generate a packet structure, a `deserialize/3` function for + deserializing binary data into the packet structure, and a `serialize/1` function for + generating the serialized binary representation of the packet. + + The `deserialize/3` function should be used for decoding received packets, and the + `serialize/1` function should be used for generating packets to be sent over the network. + + ## Field Macros + + The field macros are used to define fields within a packet: + + - `field/2`: Define a field with a name and type. + - `field/3`: Define a field with a name, type, and decoding options. + + ## Decorators + + The following decorators can be used to specify serialization and deserialization properties: + + - `@serializable true`: Marks the packet as serializable for sending over the network. + - `@deserializable true`: Marks the packet as deserializable for receiving from the network. + + """ + + ## Helpers + + defguardp is_packet_id(id) when is_integer(id) or is_binary(id) + + ## Public API + + # defpacket 0x0000 + defmacro defpacket(id) when is_packet_id(id) do + do_packet(id, id_to_name(id), nil, nil, __CALLER__) + end + + # defpacket 0x0000 when ... + defmacro defpacket({:when, _, [id, guards]}) when is_packet_id(id) do + do_packet(id, id_to_name(id), guards, nil, __CALLER__) + end + + # defpacket 0x0000, as: ModuleName + defmacro defpacket(id, as: name) when is_packet_id(id) do + do_packet(id, name, nil, nil, __CALLER__) + end + + # defpacket 0x0000 when ..., as: ModuleName + defmacro defpacket({:when, _, [id, guards]}, as: name) when is_packet_id(id) do + do_packet(id, name, guards, nil, __CALLER__) + end + + # defpacket 0x0000 do ... end + defmacro defpacket(id, do: exp) when is_packet_id(id) do + do_packet(id, id_to_name(id), nil, exp, __CALLER__) + end + + # defpacket 0x0000 when ... do ... end + defmacro defpacket({:when, _, [id, guards]}, do: exp) when is_packet_id(id) do + do_packet(id, id_to_name(id), guards, exp, __CALLER__) + end + + # defpacket 0x0000, as: ModuleName do ... end + defmacro defpacket(id, [as: name], do: exp) when is_packet_id(id) do + do_packet(id, name, nil, exp, __CALLER__) + end + + # defpacket 0x0000 when ..., as: ModuleName do ... end + defmacro defpacket({:when, _, [id, guards]}, [as: name], do: exp) when is_packet_id(id) do + do_packet(id, name, guards, exp, __CALLER__) + end + + # field :protocol_version, VarInt + defmacro field(name, type, opts \\ []) do + do_field(name, type, opts) + end + + defmacro import_packets(mod) do + deserialize_body_fun = fn %{mod: mod} -> + quote location: :keep do + unquote(mod).deserialize(var!(packet_id), var!(data), var!(socket)) + end + end + + mod = expand_aliases(mod, __CALLER__) + deserialize_ast = Enum.map(mod.__schemas__(), &def_deserialize(&1, deserialize_body_fun)) + + quote location: :keep do + (unquote_splicing(deserialize_ast)) + end + end + + defmacro __using__(_env) do + quote location: :keep do + import unquote(__MODULE__), + only: [ + defpacket: 1, + defpacket: 2, + defpacket: 3, + field: 2, + field: 3 + ] + + @before_compile unquote(__MODULE__) + @after_compile unquote(__MODULE__) + + Module.register_attribute(__MODULE__, :egn_packet_fields, accumulate: true) + Module.register_attribute(__MODULE__, :egn_packets, accumulate: true) + + @serializable false + @deserializable false + end + end + + defmacro __before_compile__(env) do + deserialize_body_fun = fn %{id: id, mod: mod} -> + quote do + unquote(mod).deserialize(unquote(id), var!(data), var!(socket)) + end + end + + egn_packets = Module.get_attribute(env.module, :egn_packets) + deserialize_functions = Enum.map(egn_packets, &def_deserialize(&1, deserialize_body_fun)) + + quote do + unquote(schemas_introspection()) + unquote_splicing(deserialize_functions) + end + end + + defmacro __after_compile__(env, _bytecode) do + env.module + |> Module.get_attribute(:egn_packets) + |> Enum.map(&def_structure/1) + |> Code.compile_quoted() + end + + ## Private funtions + + defp id_to_name(id) when is_binary(id), do: :"Elixir.#{Macro.camelize(id)}" + + defp id_to_name(id) when is_integer(id) do + raise "a module name is required for integer packet ids" + end + + defp expand_aliases(ast, env) do + Macro.postwalk(ast, fn + {:__aliases__, _, _} = alias_ast -> Macro.expand(alias_ast, env) + ast -> ast + end) + end + + defp schemas_introspection() do + quote do + @doc false + def __schemas__(), do: @egn_packets + end + end + + defp do_packet(id, name, guards, exp, caller) do + guard_env = %{caller | context: :guard} + guards = Macro.postwalk(guards, &Macro.expand(&1, guard_env)) + exp = Macro.postwalk(exp, &Macro.expand(&1, caller)) + + quote location: :keep do + if not @serializable and not @deserializable do + mod = Module.concat(__MODULE__, unquote(name)) + IO.warn("packet #{inspect(mod)} defined but not serializable nor deserializable") + end + + Module.delete_attribute(__MODULE__, :egn_packet_fields) + + unquote(exp) + + @egn_packets %{ + id: unquote(id), + name: unquote(name), + parent: __MODULE__, + mod: Module.concat(__MODULE__, unquote(name)), + guards: unquote(Macro.escape(guards)), + fields: Enum.reverse(@egn_packet_fields), + serializable: @serializable, + deserializable: @deserializable + } + + @serializable false + @deserializable false + end + end + + defp do_field(name, type, opts) do + quote location: :keep do + @egn_packet_fields %{ + name: unquote(name), + type: unquote(type), + opts: unquote(Macro.escape(opts)) + } + end + end + + defp def_serialize(%{id: id, fields: fields}) do + fields_ast = + Enum.map(fields, fn %{name: name, type: type, opts: opts} -> + quote location: :keep do + unquote(type).encode(Map.fetch!(var!(packet), unquote(name)), unquote(opts)) + end + end) + + quote location: :keep, generated: true do + def serialize(%__MODULE__{} = var!(packet)) do + {unquote(id), unquote(fields_ast)} + end + end + end + + defp def_deserialize(%{id: id, guards: guards} = packet, body_cb) do + guards = if is_nil(guards), do: true, else: guards + + quote location: :keep, generated: true do + def deserialize(var!(packet_id) = unquote(id), var!(data), var!(socket)) + when unquote(guards) do + unquote(body_cb.(packet)) + end + end + end + + # credo:disable-for-next-line + defp def_structure(packet) do + %{ + id: id, + name: name, + parent: parent, + mod: mod, + guards: guards, + fields: fields, + serializable: serializable, + deserializable: deserializable + } = packet + + fields_ast = Enum.map(fields, &decode_field_ast(&1, &1[:opts][:if])) + + deserialize_body_fun = fn %{name: name} -> + quote location: :keep do + var!(packet) = %{} + + unquote_splicing(fields_ast) + + if var!(data) != "" do + iname = inspect(unquote(name)) + raise "remaining bytes for #{inspect(__MODULE__)}.#{iname}: #{inspect(var!(data))}" + end + + struct!(__MODULE__, var!(packet)) + end + end + + quote location: :keep do + defmodule unquote(mod) do + import unquote(parent), except: [deserialize: 3, serialize: 1] + + # Structure + + @enforce_keys for f <- unquote(Macro.escape(fields)), + is_nil(f[:opts][:default]), + do: f.name + defstruct Enum.map(unquote(Macro.escape(fields)), &{&1.name, &1[:opts][:default]}) + + # Introspection + + @doc false + def __schema__(:id), do: unquote(id) + def __schema__(:name), do: unquote(name) + def __schema__(:guards), do: unquote(Macro.escape(guards)) + def __schema__(:fields), do: unquote(Macro.escape(fields)) + + # Serializer/Deserializer + + if unquote(serializable) do + unquote(def_serialize(packet)) + else + def serialize(_), do: raise("unimplemented") + end + + if unquote(deserializable) do + unquote(def_deserialize(packet, deserialize_body_fun)) + else + def deserialize(_, _, _), do: raise("unimplemented") + end + end + end + end + + defp decode_field_ast(%{name: name, type: type, opts: opts}, nil) do + quote location: :keep do + {value, var!(data)} = unquote(type).decode(var!(data), unquote(opts)) + var!(packet) = Map.put(var!(packet), unquote(name), value) + end + end + + defp decode_field_ast(%{name: name, type: type, opts: opts}, condition) do + quote location: :keep do + {value, var!(data)} = + case unquote(condition) do + result when result in [false, nil] -> {nil, var!(data)} + _ -> unquote(type).decode(var!(data), unquote(opts)) + end + + var!(packet) = Map.put(var!(packet), unquote(name), value) + end + end +end diff --git a/lib/elven_gard/network/socket.ex b/lib/elven_gard/network/socket.ex index 181565a..d832c80 100644 --- a/lib/elven_gard/network/socket.ex +++ b/lib/elven_gard/network/socket.ex @@ -1,15 +1,24 @@ defmodule ElvenGard.Network.Socket do @moduledoc ~S""" - Manage a socket + Manage a socket. + + This module provides functionality for managing a socket in the network protocol. + A socket is a connection between the server and a client. It maintains various + socket fields, such as the socket ID, socket assigns, transport information, + and the packet network encoder used for sending data. ## Socket fields - * `:id` - The string id of the socket - * `:assigns` - The map of socket assigns, default: `%{}` - * `:transport` - A [Ranch transport](https://ninenines.eu/docs/en/ranch/2.0/guide/transports/) - * `:transport_pid` - The pid of the socket's transport process - * `:remaining` - The remaining bytes after a receive and a packet deserialization - * `:encoder` - The `ElvenGard.Network.Endpoint.PacketCodec` used to encode packets in `send/2` function + - `:id`: The unique string ID of the socket. + - `:assigns`: A map of socket assigns, which can be used to store custom data + associated with the socket. The default value is `%{}`. + - `:transport`: The [Ranch transport](https://ninenines.eu/docs/en/ranch/2.0/guide/transports/) + used for the socket. + - `:transport_pid`: The PID (Process ID) of the socket's transport process. + - `:remaining`: The remaining bytes after receiving and packet deserialization. + - `:encoder`: The `ElvenGard.Network.NetworkCodec` module used to encode packets + in the `send/2` function. + """ alias __MODULE__ @@ -32,7 +41,10 @@ defmodule ElvenGard.Network.Socket do } @doc """ - Create a new structure + Create a new socket structure. + + This function initializes a new socket with the given `transport_pid`, `transport`, + and `encoder` module. """ @spec new(pid, atom, module) :: Socket.t() def new(transport_pid, transport, encoder) do @@ -46,8 +58,18 @@ defmodule ElvenGard.Network.Socket do @doc """ Send a packet to the client. + + This function sends a packet to the client through the socket's transport. + If the socket's `encoder` is set to `:unset`, the data is sent as is. + Otherwise, the `encoder` module is used to serialize the data before sending it. + + ## Examples + + iex> ElvenGard.Network.Socket.send(socket, %LoginResponse{status: 200, message: "Welcome!"}) + :ok + """ - @spec send(Socket.t(), any) :: :ok | {:error, atom} + @spec send(Socket.t(), struct() | iodata()) :: :ok | {:error, atom} def send(%Socket{encoder: :unset} = socket, data) do %Socket{transport: transport, transport_pid: transport_pid} = socket transport.send(transport_pid, data) @@ -55,7 +77,7 @@ defmodule ElvenGard.Network.Socket do def send(%Socket{} = socket, message) do %Socket{transport: transport, transport_pid: transport_pid, encoder: encoder} = socket - data = encoder.serialize(message, socket) + data = encoder.encode(message, socket) transport.send(transport_pid, data) end @@ -69,7 +91,14 @@ defmodule ElvenGard.Network.Socket do ## Examples iex> assign(socket, :name, "ElvenGard") + iex> socket.assigns.name == "ElvenGard" + true + iex> assign(socket, name: "ElvenGard", logo: "🌸") + iex> socket.assigns.name == "ElvenGard" + true + iex> socket.assigns.logo == "🌸" + true """ @spec assign(Socket.t(), atom, any) :: Socket.t() def assign(%Socket{} = socket, key, value) do diff --git a/lib/elven_gard/network/type.ex b/lib/elven_gard/network/type.ex index 59148e5..c9e4a65 100644 --- a/lib/elven_gard/network/type.ex +++ b/lib/elven_gard/network/type.ex @@ -1,13 +1,76 @@ defmodule ElvenGard.Network.Type do @moduledoc ~S""" - Define a behaviour for custom types (packet parsing) + Define a behaviour for custom types (packet parsing). + + This module defines a behaviour that allows users to create custom packet parsing + types. By implementing the callbacks defined in this module, users can define + how a specific field type is decoded and encoded when parsing and generating + packets. + + To implement a custom type, you need to define the `c:decode/2` and `c:encode/2` + callbacks. The `c:decode/2` function takes a raw binary as input and decodes it + into a structured value of the custom type. The `encode/2` function takes a value + of the custom type and encodes it into a binary representation. + + Note that the `ElvenGard.Network.PacketSerializer` module uses the callbacks in this + behaviour when encoding and decoding packet fields. For each field defined in a + packet serializer, the corresponding type's `c:encode/2` or `c:decode/2` function + will be called to parse the binary data. + + ## Example + + Here's an example of defining a custom type for decoding and encoding a 16-bit + little-endian integer: + + defmodule MyLittleEndianIntType do + use ElvenGard.Network.Type + + @impl ElvenGard.Network.Type + def decode(raw, _opts) do + <> = raw + {value, rest} + end + + @impl ElvenGard.Network.Type + def encode(value, _opts) do + <> + end + end + + In the above example, we defined a custom type module `MyLittleEndianIntType` that + decodes and encodes a 16-bit little-endian integer. + + Then, when defining a packet serializer using `ElvenGard.Network.PacketSerializer`, you + can use this custom type to define a field in the packet structure. + """ - @doc "Decode a field type" + ## Callbacks + + @doc """ + Decode a raw bitstring into a structured value of the custom type. + + Arguments: + + - `raw`: The raw bitstring to be decoded. + - `opts`: A keyword list of options that can be used during decoding (optional). + + The function should return a tuple with the decoded value and the remaining + unparsed bitstring. + """ @callback decode(raw :: bitstring, opts :: keyword) :: {any, remaining :: bitstring} - @doc "Encode a field type" - @callback encode(value :: any, opts :: keyword) :: iodata + @doc """ + Encode a value of the custom type into binary data. + + Arguments: + + - `value`: The value of the custom type to be encoded. + - `opts`: A keyword list of options that can be used during encoding (optional). + + The function should return the encoded binary data as an iodata or a bitstring. + """ + @callback encode(value :: any, opts :: keyword) :: iodata | bitstring defmacro __using__(_env) do quote location: :keep do diff --git a/lib/elven_gard/network/uuid.ex b/lib/elven_gard/network/uuid.ex index 09edca8..96bde36 100644 --- a/lib/elven_gard/network/uuid.ex +++ b/lib/elven_gard/network/uuid.ex @@ -1,7 +1,6 @@ defmodule ElvenGard.Network.UUID do - @moduledoc ~S""" - UUID helpers inspired by `zyro/elixir-uuid` - """ + @moduledoc false + # UUID helpers inspired by `zyro/elixir-uuid` ## Public API @@ -44,6 +43,8 @@ defmodule ElvenGard.Network.UUID do >> end + ## Private functions + @compile {:inline, e: 1} @doc false diff --git a/lib/elven_gard/network/view.ex b/lib/elven_gard/network/view.ex index f2d3e5f..dbd6a3f 100644 --- a/lib/elven_gard/network/view.ex +++ b/lib/elven_gard/network/view.ex @@ -1,14 +1,52 @@ defmodule ElvenGard.Network.View do @moduledoc ~S""" - Define a custom behaviour for a view (packet sent from server to client) + Define a custom behaviour for a View (packet sent from server to client). + + This module defines a behaviour that allows users to create custom packet views + to be sent from the server to the client. By implementing the callback defined + in this module, users can define how a specific packet view is rendered and + converted into binary data for transmission. + + To implement a custom view, you need to define the `c:render/2` callback. The + `c:render/2` function takes the name of the packet view and a map of parameters + as input and returns the binary data to be sent to the client or a structure + that will be serialized by `ElvenGard.Network.PacketSerializer`. + + ## Example + + Here's an example of defining a custom view for rendering a login response + packet: + + defmodule MyApp.LoginViews do + use ElvenGard.Network.View + + alias MyApp.Server.LoginPackets.LoginResponse + + @impl ElvenGard.Network.View + def render(:login_response, %{status: status, message: message}) do + # Encode the `json` field before generating the LoginResponse struct + json = Poison.encode!(message) + %LoginResponse{status: status, json: json} + end + end + + In the above example, we defined a custom views module `MyApp.LoginViews` that + implements the `c:render/2` callback for rendering a login response packet. """ alias ElvenGard.Network.UnknownViewError @doc """ - Build a packet to send to the client + Build a packet to send to the client. + + Arguments: + + - `name`: A unique identifier packet view (string or atom). + - `params`: A map or keyword list of parameters to be used when rendering the view. + + The function should return an iodata or a structure to be sent to the client. """ - @callback render(name, params) :: iodata() + @callback render(name, params) :: iodata() | struct() when name: atom() | String.t(), params: map() | Keyword.t() @doc false diff --git a/mix.exs b/mix.exs index c136875..47facf1 100644 --- a/mix.exs +++ b/mix.exs @@ -2,7 +2,7 @@ defmodule ElvenGard.Network.MixProject do use Mix.Project @app_name "ElvenGard.Network" - @version "0.1.0-alpha" + @version "0.1.0" @github_link "https://github.com/ImNotAVirus/elvengard_network" def project do @@ -11,7 +11,7 @@ defmodule ElvenGard.Network.MixProject do version: @version, elixir: "~> 1.13", name: @app_name, - description: "MMORPG Game Server toolkit written in Elixir", + description: "Game server toolkit written in Elixir # Network", elixirc_paths: elixirc_paths(Mix.env()), package: package(), docs: docs(), @@ -63,23 +63,51 @@ defmodule ElvenGard.Network.MixProject do # logo: "path/to/logo.png", extra_section: "GUIDES", extras: extras(), - groups_for_extras: groups_for_extras() - # groups_for_modules: [ - # "Textual protocol specs": ~r/ElvenGard\.Protocol\.Textual\.?/, - # "Binary protocol specs": ~r/ElvenGard\.Protocol\.Binary\.?/, - # PacketHandler: ~r/ElvenGard\.PacketHandler\./ - # ] + groups_for_extras: groups_for_extras(), + groups_for_modules: groups_for_modules() ] end - defp extras do - ["README.md": [title: "Overview"]] ++ Path.wildcard("guides/**/*.md") + defp extras() do + Enum.concat( + ["README.md": [title: "Overview"]], + [ + "CHANGELOG.md", + "guides/introduction/network_protocol.md", + "guides/introduction/getting_started.md", + "guides/introduction/endpoint.md", + "guides/introduction/protocol.md", + "guides/introduction/types_and_subpackets.md", + "guides/introduction/packet_definitions.md", + "guides/introduction/network_codec.md", + "guides/introduction/packet_views.md", + "guides/introduction/packet_handler.md" + ] + ) end - defp groups_for_extras do + defp groups_for_extras() do [ - Introduction: ~r/(README.md|guides\/introduction\/.?)/, - Topics: ~r/guides\/topics\/.?/ + Introduction: ~r/(README.md|guides\/introduction\/.?)/ + ] + end + + defp groups_for_modules() do + # Ungrouped Modules: + # + # ElvenGard.Network + # ElvenGard.Network.Socket + # ElvenGard.Network.Type + # ElvenGard.Network.View + + [ + Endpoint: [ + ElvenGard.Network.Endpoint, + ElvenGard.Network.Endpoint.Protocol, + ElvenGard.Network.NetworkCodec, + ElvenGard.Network.PacketHandler, + ElvenGard.Network.PacketSerializer + ] ] end end diff --git a/test/elven_gard/network/endpoint/protocol_test.exs b/test/elven_gard/network/endpoint/protocol_test.exs index 6a29e43..5db3196 100644 --- a/test/elven_gard/network/endpoint/protocol_test.exs +++ b/test/elven_gard/network/endpoint/protocol_test.exs @@ -46,12 +46,6 @@ defmodule ElvenGard.Network.ProtocolTest do send(socket.assigns[:link], {:handle_halt, reason}) {:ok, socket} end - - @impl true - def handle_error(reason, socket) do - send(socket.assigns[:link], {:handle_error, reason}) - {:stop, reason, socket} - end end setup_all do diff --git a/test/elven_gard/network/endpoint_test.exs b/test/elven_gard/network/endpoint_test.exs index 42b1732..789e40a 100644 --- a/test/elven_gard/network/endpoint_test.exs +++ b/test/elven_gard/network/endpoint_test.exs @@ -119,13 +119,4 @@ defmodule ElvenGard.Network.EndpointTest do refute_receive :handle_start end end - - describe "start_link/0" do - test "call c:handle_start/1" do - {:ok, _} = MyEndpoint.start_link([]) - assert_received :handle_start - # Crash tests sometimes ??? - # GenServer.stop(endpoint) - end - end end diff --git a/test/elven_gard/network/packet_schema_test.exs b/test/elven_gard/network/packet_serializer_test.exs similarity index 62% rename from test/elven_gard/network/packet_schema_test.exs rename to test/elven_gard/network/packet_serializer_test.exs index 18c2131..1f032b3 100644 --- a/test/elven_gard/network/packet_schema_test.exs +++ b/test/elven_gard/network/packet_serializer_test.exs @@ -1,8 +1,8 @@ -defmodule ElvenGard.Network.PacketSchemaTest do +defmodule ElvenGard.Network.PacketSerializerTest do use ExUnit.Case, async: true defmodule SimplePackets do - use ElvenGard.Network.PacketSchema + use ElvenGard.Network.PacketSerializer use ExUnit.Case, async: true alias ElvenGard.Network.CustomTypes.{Boolean, Date, Int, Str} @@ -10,7 +10,8 @@ defmodule ElvenGard.Network.PacketSchemaTest do ## Packets def - packet "my_simple_packet" do + @deserializable true + defpacket "my_simple_packet" do field :id, Int field :name, Str field :enabled, Boolean @@ -24,6 +25,10 @@ defmodule ElvenGard.Network.PacketSchemaTest do %{ id: "my_simple_packet", name: MySimplePacket, + parent: ElvenGard.Network.PacketSerializerTest.SimplePackets, + mod: ElvenGard.Network.PacketSerializerTest.SimplePackets.MySimplePacket, + serializable: false, + deserializable: true, guards: nil, fields: [ %{name: :id, type: ElvenGard.Network.CustomTypes.Int, opts: []}, @@ -55,13 +60,14 @@ defmodule ElvenGard.Network.PacketSchemaTest do ] end - describe "decode/3" do + describe "deserialize/3" do test "is defined" do - assert function_exported?(SimplePackets, :decode, 3) + assert function_exported?(SimplePackets, :deserialize, 3) end test "parse a binary and returns a structure" do - packet = SimplePackets.decode("my_simple_packet", "1337 Admin 1 2023-07-21", %Socket{}) + packet = + SimplePackets.deserialize("my_simple_packet", "1337 Admin 1 2023-07-21", %Socket{}) assert packet.__struct__ == __MODULE__.MySimplePacket assert packet.id == 1337 @@ -72,14 +78,14 @@ defmodule ElvenGard.Network.PacketSchemaTest do test "raise an error if remaining bytes" do assert_raise RuntimeError, ~r/remaining bytes for /, fn -> - SimplePackets.decode("my_simple_packet", "1337 Admin 1 2023-07-21 foo", %Socket{}) + SimplePackets.deserialize("my_simple_packet", "1337 Admin 1 2023-07-21 foo", %Socket{}) end end end end defmodule StringPackets do - use ElvenGard.Network.PacketSchema + use ElvenGard.Network.PacketSerializer use ExUnit.Case, async: true alias ElvenGard.Network.CustomTypes.{Boolean, Str} @@ -87,104 +93,115 @@ defmodule ElvenGard.Network.PacketSchemaTest do ## Packets def - packet "no_field" + @deserializable true + defpacket "no_field" - packet "no_field_but_guard" when socket.assigns.state == :foo + @deserializable true + defpacket "no_field_but_guard" when socket.assigns.state == :foo - packet "no_field_but_name", as: NoFieldButName2 + @deserializable true + defpacket "no_field_but_name", as: NoFieldButName2 - packet "no_field_but_name_and_guard" when socket.assigns.state == :foo, + @deserializable true + defpacket "no_field_but_name_and_guard" when socket.assigns.state == :foo, as: NoFieldButNameAndGuard2 - packet "no_field_but_name_and_guard" when socket.assigns.state == :bar, + @deserializable true + defpacket "no_field_but_name_and_guard" when socket.assigns.state == :bar, as: NoFieldButNameAndGuard3 - packet "with_empty_fields", do: :ok + @deserializable true + defpacket "with_empty_fields", do: :ok - packet "with_guards" when socket.assigns.state == :foo do + @deserializable true + defpacket "with_guards" when socket.assigns.state == :foo do field :value, Str end - packet "with_name", as: WithName2 do + @deserializable true + defpacket "with_name", as: WithName2 do field :value, Str end - packet "with_guards_and_name" when socket.assigns.state == :foo, as: WithGuardsAndName2 do + @deserializable true + defpacket "with_guards_and_name" when socket.assigns.state == :foo, as: WithGuardsAndName2 do field :value, Str end - packet "with_options" do + @deserializable true + defpacket "with_options" do field :value, Str, fill: true end - packet "with_condition" do + @deserializable true + defpacket "with_condition" do field :enabled, Boolean field :value, Str, if: packet.enabled end ## Tests - describe "decode packet with" do + describe "deserialize packet with" do test "no field" do - assert packet = decode("no_field") + assert packet = deserialize("no_field") assert packet.__struct__ == __MODULE__.NoField end test "no field but guards" do - assert packet = decode("no_field_but_guard", state: :foo) + assert packet = deserialize("no_field_but_guard", state: :foo) assert packet.__struct__ == __MODULE__.NoFieldButGuard - assert_raise FunctionClauseError, fn -> decode("no_field_but_guard", state: :bar) end + assert_raise FunctionClauseError, fn -> deserialize("no_field_but_guard", state: :bar) end end test "no field but name" do - assert packet = decode("no_field_but_name") + assert packet = deserialize("no_field_but_name") assert packet.__struct__ == __MODULE__.NoFieldButName2 end test "no field but name and guard" do - assert packet = decode("no_field_but_name_and_guard", state: :foo) + assert packet = deserialize("no_field_but_name_and_guard", state: :foo) assert packet.__struct__ == __MODULE__.NoFieldButNameAndGuard2 - assert packet = decode("no_field_but_name_and_guard", state: :bar) + assert packet = deserialize("no_field_but_name_and_guard", state: :bar) assert packet.__struct__ == __MODULE__.NoFieldButNameAndGuard3 end test "empty field" do - assert packet = decode("with_empty_fields") + assert packet = deserialize("with_empty_fields") assert packet.__struct__ == __MODULE__.WithEmptyFields end test "guards" do - assert packet = decode("with_guards", raw: "bar", state: :foo) + assert packet = deserialize("with_guards", raw: "bar", state: :foo) assert packet.__struct__ == __MODULE__.WithGuards assert packet.value == "bar" end test "name" do - assert packet = decode("with_name", raw: "bar") + assert packet = deserialize("with_name", raw: "bar") assert packet.__struct__ == __MODULE__.WithName2 assert packet.value == "bar" end test "guards and name" do - assert packet = decode("with_guards_and_name", raw: "bar", state: :foo) + assert packet = deserialize("with_guards_and_name", raw: "bar", state: :foo) assert packet.__struct__ == __MODULE__.WithGuardsAndName2 assert packet.value == "bar" end test "options" do - assert packet = decode("with_options", raw: "foo bar") + assert packet = deserialize("with_options", raw: "foo bar") assert packet.__struct__ == __MODULE__.WithOptions assert packet.value == "foo bar" end test "condition" do - assert packet = decode("with_condition", raw: "0") + assert packet = deserialize("with_condition", raw: "0") assert packet.__struct__ == __MODULE__.WithCondition assert packet.enabled == false assert packet.value == nil - assert packet = decode("with_condition", raw: "1 foobar") + assert packet = deserialize("with_condition", raw: "1 foobar") assert packet.__struct__ == __MODULE__.WithCondition assert packet.enabled == true assert packet.value == "foobar" @@ -193,9 +210,9 @@ defmodule ElvenGard.Network.PacketSchemaTest do ## Helpers - defp decode(name, opts \\ []) do + defp deserialize(name, opts \\ []) do assigns = if opts[:state], do: %{state: opts[:state]}, else: nil - StringPackets.decode(name, opts[:raw] || "", %Socket{assigns: assigns}) + StringPackets.deserialize(name, opts[:raw] || "", %Socket{assigns: assigns}) end end @@ -208,8 +225,10 @@ defmodule ElvenGard.Network.PacketSchemaTest do assert_raise RuntimeError, ~r/a module name is required for integer packet ids/, fn -> Code.compile_string(""" defmodule CantCompile do - use ElvenGard.Network.PacketSchema - packet 0x00, do: :ok + use ElvenGard.Network.PacketSerializer + + @deserializable true + defpacket 0x00, do: :ok end """) end diff --git a/test/support/dummy_encoder.ex b/test/support/dummy_encoder.ex index 65f68f7..c017430 100644 --- a/test/support/dummy_encoder.ex +++ b/test/support/dummy_encoder.ex @@ -1,14 +1,14 @@ defmodule ElvenGard.Network.DummyEncoder do @moduledoc false - @behaviour ElvenGard.Network.Endpoint.PacketCodec - @impl true + @behaviour ElvenGard.Network.NetworkCodec - def next(_raw), do: raise("unimplemented") + @impl true + def next(_raw, _socket), do: raise("unimplemented") @impl true - def deserialize(_raw, _socket), do: raise("unimplemented") + def decode(_raw, _socket), do: raise("unimplemented") @impl true - def serialize(raw, _socket), do: raw + def encode(raw, _socket), do: raw end