Compare commits

..

No commits in common. "6ad910d4b47a9a6bf1ec0846bef676e1134e5558" and "4e629fb8e453a4261b4329b5259ea8b6521625ea" have entirely different histories.

36 changed files with 850 additions and 2002 deletions

View File

@ -2,6 +2,8 @@ name: Build & Test
on:
push:
branches:
- main
pull_request:
jobs:

View File

@ -24,13 +24,8 @@ defmodule Amethyst.Application do
@impl true
def start(_type, _args) do
children = [
{DynamicSupervisor, name: Amethyst.ConnectionSupervisor},
{Task.Supervisor, name: Amethyst.ConnectionSupervisor},
{Amethyst.Keys, 1024},
{Amethyst.GameCoordinator, %Amethyst.GameCoordinator.State{games: %{}, gid: 0}},
{PartitionSupervisor,
child_spec: DynamicSupervisor.child_spec([]),
name: Amethyst.GameMetaSupervisor
}
]
children = case Application.fetch_env!(:amethyst, :port) do

View File

@ -1,261 +0,0 @@
# Amethyst - An experimental Minecraft server written in Elixir.
# Copyright (C) 2024 KodiCraft
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
defmodule Amethyst.ConnectionHandler do
@moduledoc """
This module is responsible for handling incoming packets and sending outgoing packets. It keeps track of what state the connection is in and which game should
receive the packets.
"""
require Logger
@spec child_spec(:gen_tcp.socket()) :: Supervisor.child_spec()
def child_spec(socket) do
%{
id: __MODULE__,
start: {__MODULE__, :start, [socket, Amethyst.ConnectionState.Handshake, 0]}
}
end
@spec start(:gen_tcp.socket(), atom(), integer()) :: no_return()
def start(socket, connstate, version) do
{:ok, spawn(fn ->
Process.set_label("ConnectionHandler for #{inspect(socket)}")
loop(socket, connstate, version, %{})
end)}
end
@spec start_link(:gen_tcp.socket(), atom(), integer()) :: no_return()
def start_link(socket, connstate, version) do
{:ok, spawn_link(fn ->
Process.set_label("ConnectionHandler for #{inspect(socket)}")
loop(socket, connstate, version, %{})
end)}
end
@spec loop(:gen_tcp.socket(), atom(), integer(), map()) :: no_return()
defp loop(socket, connstate, version, state) do
receive do
:closed ->
Logger.info("Connection #{inspect(socket)} closed.")
Process.exit(self(), :normal)
{:disconnect, reason} ->
disconnect(socket, reason, connstate, version)
Process.exit(self(), :normal)
{:set_state, newstate} ->
Logger.debug("Switching to state #{newstate} from #{connstate}")
loop(socket, newstate, version, state)
{:set_version, newversion} ->
Logger.debug("Switching to version #{newversion} from #{version}")
loop(socket, connstate, newversion, state)
{:set_position, position} ->
prev_position = Map.get(state, :position)
state = Map.put(state, :position, position)
# If there was no prev position, we consider that we
# definitely moved
prev_cp = if prev_position == nil do nil else chunk_pos(elem(prev_position, 0), elem(prev_position, 2)) end
cp = chunk_pos(elem(position, 0), elem(position, 2))
if prev_cp != cp do
Logger.debug("Client entered new chunk #{inspect(cp)}")
# We changed chunk borders, update center chunk and begin sending new chunks
send(self(), {:send_packet, %{
packet_type: :set_center_chunk,
chunk_x: elem(cp, 0),
chunk_z: elem(cp, 1)
}})
# Figure out which new chunks are visible
prev_chunks =
if prev_cp == nil do
MapSet.new([])
else
MapSet.new(visible_chunks_from(elem(prev_cp, 0), elem(prev_cp, 1), Map.get(state, :view_distance, 16)))
end
chunks = MapSet.new(visible_chunks_from(elem(cp, 0), elem(cp, 1), Map.get(state, :view_distance, 16)))
new_chunks = MapSet.difference(chunks, prev_chunks)
Logger.debug("Sending #{MapSet.size(new_chunks)} chunks...")
# We can process all chunks in parallel
me = self()
ts = state |> Map.get(:game) |> Map.get(:refs) |> Map.get(:task_supervisor)
Task.Supervisor.async(ts, fn ->
Task.Supervisor.async_stream(ts,
new_chunks,
fn chunk -> process_chunk(me, chunk, state) end,
[ordered: false]
) |> Stream.run() end)
end
loop(socket, connstate, version, state)
{:send_packet, packet} ->
# Logger.debug("Sending packet #{inspect(packet)}")
send_packet(socket, connstate, packet, version)
loop(socket, connstate, version, state)
after 0 ->
receive do
{:packet, id, data} ->
state = handle_packet(id, data, connstate, version, state)
loop(socket, connstate, version, state)
end
end
end
defp chunk_pos(x, z) do
{floor(round(x) / 16.0), floor(round(z) / 16.0)}
end
# x, z here is chunk position
defp visible_chunks_from(x, z, view_distance) do
(x - view_distance - 3 .. x + view_distance + 3) |> Enum.flat_map(fn ix ->
(z - view_distance - 3 .. z + view_distance + 3) |> Enum.map(fn iz ->
{ix, iz}
end)
end)
end
defp process_chunk(to, chunk, state) do
import Amethyst.NBT.Write
alias Amethyst.Minecraft.Write
chunk_array = Amethyst.Game.chunk(Map.get(state, :game), chunk)
{cx, cz} = chunk
# TODO: Actually do heightmaps
# TODO: Doing all this processing could be at home somewhere else, as here it's
# not version-agnostic here
heightmaps = compound(%{})
data = Enum.chunk_every(chunk_array, 16, 16, 0) # 0 -> air
|> Enum.reduce("", fn chunk_section, acc ->
blocks = chunk_section |> List.flatten()
block_count = blocks |> Enum.filter(&(&1 != 0)) |> length
# Put together the palette
unique_blocks = MapSet.new(blocks)
min_bpe = MapSet.size(unique_blocks) |> :math.log2() |> ceil()
paletted_container_data = case min_bpe do
0 ->
# SINGLE VALUED
Write.ubyte(0) <>
Write.varint(MapSet.to_list(unique_blocks) |> List.first()) <>
Write.varint(0) # No data, empty pallette
min_bpe when min_bpe in 1..8 ->
# INDIRECT
# Minimum bpe accepted by minecraft is 4
bpe = max(min_bpe, 4)
palette = MapSet.to_list(unique_blocks) |>
Enum.with_index() |>
Map.new(fn {i, v} -> {i, v} end)
paletted_blocks = blocks |>
Enum.map(&(Map.get(palette, &1)))
paletted_data = long_aligned_bit_string_reduce(paletted_blocks, bpe)
Write.ubyte(bpe) <>
Write.varint(map_size(palette)) <>
Enum.reduce(palette, "", fn {_k, v}, acc ->
acc <> Write.varint(v)
end) <>
Write.varint(floor(bit_size(paletted_data) / 64)) <>
paletted_data
_ ->
# DIRECT
data = long_aligned_bit_string_reduce(blocks, 15)
Write.ubyte(15) <>
Write.varint(floor(bit_size(data) / 64)) <>
data
end
acc <> Write.short(block_count) <> paletted_container_data <>
<<0::8, 0::8, 0::8>> # TODO: This should be biome data
end)
send(to, {:send_packet, %{
packet_type: :chunk_data_and_update_light,
chunk_x: cx, chunk_z: cz,
heightmaps: heightmaps,
data: data,
block_entities: [],
# TODO: Light
sky_light_mask: Write.varint(0),
block_light_mask: Write.varint(0),
empty_sky_light_mask: Write.varint(0),
empty_block_light_mask: Write.varint(0),
sky_light_arrays: [],
block_light_arrays: []
}})
:ok
end
defp long_aligned_bit_string_reduce(values, bpe) do
values |> Enum.reduce(<<>>, fn value, acc ->
next = <<acc::bitstring, value::big-size(bpe)>>
# man i hope they dont suddenly change the size of a long
if rem(bit_size(next), 64) + bpe > 64 do
# gotta pad it
<<next::bitstring, 0::big-size(64 - rem(bit_size(next), 64))>>
else
next
end
end)
end
defp handle_packet(id, data, connstate, version, state) do
try do
packet = connstate.deserialize(id, version, data)
case connstate.handle(packet, version, state) do
:ok -> state
{:error, reason} ->
Logger.error("Error handling packet with ID #{inspect(id, base: :hex)} in state #{connstate}: #{reason}")
send(self(), {:disconnect, "§cError handling packet #{inspect(id, base: :hex)}:\n#{reason}"})
state
newstate ->
if is_map(newstate) do
newstate
else
Logger.warning("State change to #{inspect(newstate)} is not a map! Did you forget to return :ok?")
state
end
end
rescue
e ->
if Mix.env() == :dev do
Logger.error("Error handling packet with ID #{inspect(id, base: :hex)} in state #{connstate}: #{Exception.format(:error, e, __STACKTRACE__)}")
else
send(self(), {:disconnect, "§cError handling packet #{inspect(id, base: :hex)}:\n#{Exception.format(:error, e, __STACKTRACE__)}"})
Logger.error("Error handling packet with ID #{inspect(id, base: :hex)} in state #{connstate}: #{Exception.format(:error, e, __STACKTRACE__)}")
end
state
end
end
defp send_packet(socket, connstate, packet, version) do
try do
data = connstate.serialize(packet, version)
length = byte_size(data) |> Amethyst.Minecraft.Write.varint()
:gen_tcp.send(socket, length <> data)
rescue
e ->
Logger.error("Error sending packet #{inspect(packet)} in state #{connstate}: #{Exception.format(:error, e, __STACKTRACE__)}")
send(self(), {:disconnect, "§cError sending packet #{inspect(packet)}:\n#{Exception.format(:error, e, __STACKTRACE__)}"})
end
end
defp disconnect(socket, reason, connstate, version) do
Logger.info("Disconnecting connection #{inspect(socket)}")
Logger.debug("Disconnecting connection #{inspect(socket)}: #{reason}")
case connstate.disconnect(reason) do
nil -> nil
packet -> send_packet(socket, connstate, packet, version)
end
:gen_tcp.close(socket)
end
end

View File

@ -1,87 +0,0 @@
# Amethyst - An experimental Minecraft server written in Elixir.
# Copyright (C) 2024 KodiCraft
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
defmodule Amethyst.ConnectionReceiver do
@moduledoc """
This module waits for data incoming through a TCP connection, reads entire packets at a time and sends them to the handler.
"""
require Logger
alias Amethyst.Minecraft.Read
@spec child_spec(:gen_tcp.socket()) :: Supervisor.child_spec()
def child_spec(socket) do
%{
id: __MODULE__,
start: {__MODULE__, :start, [socket]}
}
end
@spec start(:gen_tcp.socket()) :: no_return()
def start(socket) do
{:ok, spawn(fn ->
Process.set_label("ConnectionReceiver for #{inspect(socket)}")
{:ok, pid} = Amethyst.ConnectionHandler.start_link(socket, Amethyst.ConnectionState.Handshake, 0)
receive(socket, pid)
end)}
end
@spec start_link(:gen_tcp.socket()) :: no_return()
def start_link(socket) do
{:ok, spawn_link(fn ->
Process.set_label("ConnectionReceiver for #{inspect(socket)}")
{:ok, pid} = Amethyst.ConnectionHandler.start_link(socket, Amethyst.ConnectionState.Handshake, 0)
receive(socket, pid)
end)}
end
@spec receive(:gen_tcp.socket(), pid()) :: no_return()
def receive(socket, sender) do
case get_packet(socket) do
:closed -> send(sender, :closed)
Process.exit(self(), :normal)
{:error, error} -> Logger.error("Error reading packet: #{error}")
{id, data} -> send(sender, {:packet, id, data})
end
receive(socket, sender)
end
def get_packet(client) do
case get_varint(client, "") do
:closed -> :closed
{:error, error} -> {:error, error}
{[length], ""} ->
recv = :gen_tcp.recv(client, length)
case recv do
{:ok, full_packet} -> ({[id], data} = Read.start(full_packet) |> Read.varint() |> Read.stop()
{id, data})
{:error, :closed} -> :closed
{:error, error} -> {:error, error}
end
end
end
defp get_varint(client, acc) do
case :gen_tcp.recv(client, 1) do
{:ok, byte} -> case byte do
<<0::1, _::7>> -> Read.start(acc <> byte) |> Read.varint() |> Read.stop()
<<1::1, _::7>> -> get_varint(client, acc <> byte)
end
{:error, :closed} -> :closed
{:error, error} -> {:error, error}
end
end
end

View File

@ -1,131 +0,0 @@
# Amethyst - An experimental Minecraft server written in Elixir.
# Copyright (C) 2024 KodiCraft
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
defmodule Amethyst.GameCoordinator do
use GenServer
@moduledoc """
The game coordinator is responsible for keeping track of active
instances of each game and can create new ones on demand.
"""
require Logger
defmodule State do
@moduledoc """
This struct represents the state tracked by a Amethyst.GameCoordinator.State
It contains two fields: `gid` which represents the latest gid (game id) and must be incremented every
time a game is created and `games` which is a map linking each gid to a Amethyst.GameCoordinator.Game
"""
alias Amethyst.GameCoordinator.Game
defstruct gid: 0, games: %{0 => Game}
end
defmodule Game do
@moduledoc """
This module represents an individual game instance that is currently active. Each game has a module
which defines callbacks for various events, a map containing pids to the game's state (called references
or refs) and optional metadata.
"""
defstruct mod: :none, refs: %{}, opts: [], gid: 0
end
@spec start_link(State) :: {:ok, pid()}
def start_link(initial) do
GenServer.start_link(__MODULE__, initial, name: {:global, __MODULE__})
end
@impl true
@spec init(State) :: {:ok, State}
def init(initial) do
{:ok, initial}
end
@impl true
def handle_call({:find, type}, _from, state) do
{game, state} = _find(type, state)
{:reply, game, state}
end
@impl true
def handle_call({:create, type}, _from, state) do
{game, state} = _create(type, state)
{:reply, game, state}
end
@impl true
def handle_call({:find_or_create, type}, from, state) do
{game, state} = _find(type, state)
case game do
nil -> handle_call({:create, type}, from, state)
some -> {:reply, some, state}
end
end
@impl true
def handle_cast({:remove, gid}, state) do
{_, state} = _remove(gid, state)
{:noreply, state}
end
@spec _create(atom(), State.t()) :: {Game.t(), State.t()}
defp _create(type, state) do
# Create a DynamicSupervisor for this game
{:ok, game_supervisor_pid} = DynamicSupervisor.start_child(
{:via, PartitionSupervisor, {Amethyst.GameMetaSupervisor, type}},
DynamicSupervisor
)
{:ok, task_supervisor_pid} = DynamicSupervisor.start_child(
game_supervisor_pid,
Task.Supervisor
)
# TODO: Instantiation can fail (including with an exception), and if it does the entire GameCoordinator goes down
# We should gracefully handle situations where we cannot create a game.
{:ok, refs} = type.instantiate(game_supervisor_pid)
refs = refs |> Map.put(:game_supervisor, game_supervisor_pid) |> Map.put(:task_supervisor, task_supervisor_pid) |> Map.put(:game_coordinator, self())
game = %Game{
mod: type, refs: refs, opts: [], gid: state.gid
}
games = state.games |> Map.put(state.gid, game)
Logger.info("Created new game of type #{inspect(type)} with gid #{state.gid}")
{game, %State{gid: state.gid + 1, games: games}}
end
defp _find(type, state) do
case state.games |> Enum.find(fn {_, game} -> game.mod == type && game.mod.joinable?(game.refs) end) do
nil -> {nil, state}
{_, game} -> {game, state}
end
end
defp _remove(gid, state) do
games = state.games |> Map.delete(gid)
Logger.info("Removed game with gid #{gid}")
{games, %State{state | games: games}}
end
def create(type) when is_atom(type) do
GenServer.call({:global, __MODULE__}, {:create, type})
end
def find(type) when is_atom(type) do
GenServer.call({:global, __MODULE__}, {:find, type})
end
def find_or_create(type) when is_atom(type) do
GenServer.call({:global, __MODULE__}, {:find_or_create, type})
end
def remove(gid) when is_integer(gid) do
GenServer.cast({:global, __MODULE__}, {:remove, gid})
end
end

View File

@ -21,7 +21,7 @@ defmodule Amethyst.TCPListener do
"""
def accept(port) do
{:ok, socket} = :gen_tcp.listen(port, [:binary, packet: 0, active: false, reuseaddr: true, nodelay: true])
{:ok, socket} = :gen_tcp.listen(port, [:binary, packet: 0, active: false, reuseaddr: true])
Logger.info("Listening on port #{port}")
loop_acceptor(socket)
end
@ -29,7 +29,7 @@ defmodule Amethyst.TCPListener do
@spec loop_acceptor(socket :: :gen_tcp.socket()) :: no_return()
defp loop_acceptor(socket) do
{:ok, client} = :gen_tcp.accept(socket)
{:ok, pid} = DynamicSupervisor.start_child(Amethyst.ConnectionSupervisor, {Amethyst.ConnectionReceiver, client})
{:ok, pid} = Task.Supervisor.start_child(Amethyst.ConnectionSupervisor, fn -> Amethyst.Server.Handshake.serve(client) end)
:ok = :gen_tcp.controlling_process(client, pid)
Logger.info("Received connection from #{inspect(client)}, assigned to PID #{inspect(pid)}")
loop_acceptor(socket)

View File

@ -35,10 +35,6 @@ defmodule Amethyst.Minecraft.Write do
end
end
def raw(value) do
value
end
def byte(value) when value in -128..127 do
<<value::8-signed-big>>
end
@ -102,14 +98,6 @@ defmodule Amethyst.Minecraft.Write do
<<varint(byte_size(value))::binary, value::binary>>
end
def json(value) do
string(Jason.encode!(value))
end
def byte_array(value) do
<<varint(byte_size(value))::binary, value::binary>>
end
def position({x, y, z}) do
<<x::signed-big-26, z::signed-big-26, y::signed-big-12>>
end
@ -135,10 +123,6 @@ defmodule Amethyst.Minecraft.Write do
v -> bool(true) <> callback.(v)
end
end
def nbt(value) do
Amethyst.NBT.Write.write_net(value)
end
end
defmodule Amethyst.Minecraft.Read do
@ -259,11 +243,4 @@ defmodule Amethyst.Minecraft.Read do
<<value::binary-size(length), rest::binary>> = rest
{[value | acc], rest, :reversed}
end
def json({acc, data, :reversed}) do
{[value], rest, :reversed} = string({[], data, :reversed})
{[Jason.decode!(value) | acc], rest, :reversed}
end
def raw({acc, data, :reversed}) do
{[data | acc], "", :reversed}
end
end

View File

@ -1,107 +0,0 @@
# Amethyst - An experimental Minecraft server written in Elixir.
# Copyright (C) 2024 KodiCraft
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
defmodule Amethyst.Game do
@moduledoc """
This behaviour should be implemented by any Amethyst Game. It additionally
contains functions that the internal connection handler code uses to more
conveniently call a game's callbacks.
"""
@doc """
`instantiate/1` is called when a new instance of your game is created. You may start any additional
processes you want under the `DynamicSupervisor` 'supervisor' and return a map with the PIDs to those
processes, known as your references. Other callbacks in your game will receive these references and
may use them to access and update state.
"""
@callback instantiate(supervisor :: pid()) :: {:ok, state_refs :: map()} | {:error, reason :: term}
@doc """
`login/3` is called when a new player logs into a game instance.
You may either :accept or :reject the player for whatever reason, avoid
rejecting the player in your default game as that will disconnect the player.
If you :accept the player, you must return a spawn position ({x, y, z}) and
rotation ({yaw, pitch})
Note that if no new players can join for any reason, your game should return false from `joinable?/1`.
The PID received in 'from' will be the one that calls all callbacks
caused directly by this player, such as their movement or interactions --
You can expect that PID to never be used by any other player and that this same
player will not use another PID until they disconnect from the server.
- 'from' is the PID of the player's connection process.
- 'player_cfg' is a keyword list containing the configuration passed by the game client
- 'state_refs' are your references (see `instantiate/1`)
"""
@callback login(from :: pid(), player_cfg :: keyword(), state_refs :: map()) :: {:accept, {x :: float(), y :: float(), z :: float()}, {yaw :: float(), pitch ::float()}} | :reject
def login(%{:mod => mod, :refs => refs}, player_cfg) do
mod.login(self(), player_cfg, refs)
end
@doc """
`player_position/3` is called when a player moves. This function is called with the absolute coordinates
that the player client expects. TODO: Teleport Player API.
- 'from' is the PID of the player's connection process (see `login/3`).
- 'x', 'y' and 'z' are the absolute coordinates that the player wants to move to.
- `state_refs` are your references (see `instantiate/1`)
"""
@callback player_position(from :: pid(), {x :: float(), y :: float(), z :: float()}, state_refs :: map()) :: :ok
def player_position(%{:mod => mod, :refs => refs}, {x, y, z}) do
mod.player_position(self(), {x, y, z}, refs)
end
@doc """
`player_rotation/3` is called when a player rotates. This function is called with the absolute angles
that the player client expects. TODO: Teleport Player API.
- 'from' is the PID of the player's connection process (see `login/3`).
- 'yaw' and 'pitch' are the angles the player expects to rotate to. These are in Minecraft's rotation format.
- `state_refs` are your references (see `instantiate/1`)
"""
@callback player_rotation(from :: pid(), {yaw :: float(), pitch :: float()}, state_refs :: map()) :: :ok
def player_rotation(%{:mod => mod, :refs => refs}, {yaw, pitch}) do
mod.player_rotation(self(), {yaw, pitch}, refs)
end
@doc """
`accept_teleport/3` is called when a client accepts a teleportation as sent by the Synchronize Player Position
packet (TODO: Teleport Player API). This lets you know that the client is now where you expect it to be.
- 'from' is the PID of the player's connection process (see `login/3`).
- 'id' is the teleport ID (TODO: Teleport Player API)
- 'state_refs' are your references (see `instantiate/1`)
"""
@callback accept_teleport(from :: pid(), id :: integer(), state_refs :: map()) :: :ok
def accept_teleport(%{:mod => mod, :refs => refs}, id) do
mod.accept_teleport(self(), id, refs)
end
@doc """
The terrain of a specific chunk column. This is automatically used to load chunks for a player.
For now, this data must be formatted as a 3D list, indexed as [y][z][x].
"""
@callback chunk(from :: pid(), {x :: integer(), z :: integer()}, state_refs :: map()) :: [[[pos_integer()]]]
def chunk(%{:mod => mod, :refs => refs}, pos) do
mod.chunk(self(), pos, refs)
end
@doc """
Whether or not this game instance can be joined by a new player. This should include basic logic such as
if joining makes sense, for instance if the game is full or if the game has already started.
"""
@callback joinable?(state_refs :: map()) :: boolean()
def joinable?(%{:mod => mod, :refs => refs}) do
mod.joinable?(refs)
end
end

View File

@ -27,20 +27,6 @@ defmodule Amethyst.NBT.Write do
<<type_id(type)::size(8), payload(type, value)::binary>>
end
def check_type({:byte, value}) when is_integer(value) and value in -128..127, do: true
def check_type({:short, value}) when is_integer(value) and value in -32_768..32_767, do: true
def check_type({:int, value}) when is_integer(value) and value in -2_147_483_648..2_147_483_647, do: true
def check_type({:long, value}) when is_integer(value) and value in -9_223_372_036_854_775_808..9_223_372_036_854_775_807, do: true
def check_type({:float, value}) when is_float(value), do: true
def check_type({:double, value}) when is_float(value), do: true
def check_type({:byte_array, values}) when is_list(values), do: Enum.all?(values, &is_integer/1)
def check_type({:string, value}) when is_binary(value), do: true
def check_type({:list, {type, values}}) when is_list(values), do: Enum.all?(values, &check_type({type, &1}))
def check_type({:compound, values}) when is_map(values), do: Enum.all?(values, fn {_name, {type, value}} -> check_type({type, value}) end)
def check_type({:int_array, values}) when is_list(values), do: Enum.all?(values, &is_integer/1)
def check_type({:long_array, values}) when is_list(values), do: Enum.all?(values, &is_integer/1)
def check_type(_), do: false
defp type_id(:end), do: 0
defp type_id(:byte), do: 1
defp type_id(:short), do: 2

View File

@ -0,0 +1,355 @@
# Amethyst - An experimental Minecraft server written in Elixir.
# Copyright (C) 2024 KodiCraft
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
defmodule Amethyst.Server.Configuration do
@moduledoc """
This module contains the logic for the Configuration stage of the server.
"""
require Logger
use Amethyst.Server
alias Amethyst.Minecraft.Read
alias Amethyst.Minecraft.Write
@impl true
def init(state) do
state
end
## DESERIALIZATION
@impl true
# Client Information https://wiki.vg/Protocol#Client_Information
def deserialize(0x00, data) do
{[locale, view_dist, chat_mode, chat_colors, displayed_skin_parts, main_hand, text_filtering, allow_listing], ""} =
Read.start(data) |> Read.string |> Read.byte |> Read.varint |> Read.bool |> Read.ubyte |> Read.varint |> Read.bool |> Read.bool |> Read.stop
chat_mode = case chat_mode do
0 -> :enabled
1 -> :commands_only
2 -> :hidden
_ -> raise RuntimeError, "Unknown chat mode #{chat_mode}"
end
main_hand = case main_hand do
0 -> :left
1 -> :right
_ -> raise RuntimeError, "Unknown main hand #{main_hand}"
end
{:client_information, locale, view_dist, chat_mode, chat_colors, displayed_skin_parts, main_hand, text_filtering, allow_listing}
end
# Cookie Response https://wiki.vg/Protocol#Cookie_Response_(configuration)
def deserialize(0x01, data) do
{[key, exists], rest} = Read.start(data) |> Read.string |> Read.bool |> Read.stop
if exists do
{[length], rest} = Read.start(rest) |> Read.varint |> Read.stop
{[data], _} = Read.start(rest) |> Read.raw(length) |> Read.stop
{:cookie_response, key, data}
else
{:cookie_response, key, nil}
end
end
# Serverbound Plugin Message https://wiki.vg/Protocol#Serverbound_Plugin_Message_(configuration)
def deserialize(0x02, data) do
{[channel], rest} = Read.start(data) |> Read.string |> Read.stop
{:serverbound_plugin_message, channel, rest}
end
# Acknowledge Finish Configuration https://wiki.vg/Protocol#Acknowledge_Finish_Configuration
def deserialize(0x03, "") do
{:acknowledge_finish_configuration}
end
# Serverbound Keep Alive https://wiki.vg/Protocol#Serverbound_Keep_Alive_(configuration)
def deserialize(0x04, data) do
{[id], ""} = Read.start(data) |> Read.long |> Read.stop
{:serverbound_keep_alive, id}
end
# Pong https://wiki.vg/Protocol#Pong_(configuration)
def deserialize(0x05, data) do
{[id], ""} = Read.start(data) |> Read.int |> Read.stop
{:pong, id}
end
# Resource Pack Response https://wiki.vg/Protocol#Resource_Pack_Response_(configuration)
def deserialize(0x06, data) do
{[uuid, result], ""} = Read.start(data) |> Read.uuid |> Read.varint |> Read.stop
result = case result do
0 -> :successfully_downloaded
1 -> :declined
2 -> :failed_to_download
3 -> :accepted
4 -> :downloaded
5 -> :invalid_url
6 -> :failed_to_reload
7 -> :discarded
_ -> raise RuntimeError, "Unknown resource pack response #{result}"
end
{:resource_pack_response, uuid, result}
end
# Serverbound Known Packs https://wiki.vg/Protocol#Serverbound_Known_Packs
def deserialize(0x07, data) do
{[count], rest} = Read.start(data) |> Read.varint |> Read.stop
{packs, _} = Enum.reduce(1..count, {[], rest}, fn _, {acc, rest} ->
{[namespace, id, version], rest} = Read.start(rest) |> Read.string |> Read.string |> Read.string |> Read.stop
{[{namespace, id, version} | acc], rest}
end)
{:serverbound_known_packs, packs}
end
def deserialize(type, _) do
raise RuntimeError, "Got unknown packet type #{type}!"
end
## SERIALIZATION
@impl true
# Cookie Request https://wiki.vg/Protocol#Cookie_Request_(configuration)
def serialize({:cookie_request, id}) do
Write.varint(0x00) <> Write.string(id)
end
# Clientbound Plugin Message https://wiki.vg/Protocol#Clientbound_Plugin_Message_(configuration)
def serialize({:clientbound_plugin_message, channel, data}) do
Write.varint(0x01) <> Write.string(channel) <> data
end
# Disconnect https://wiki.vg/Protocol#Disconnect_(configuration)
def serialize({:disconnect, reason}) do
Write.varint(0x02) <> Write.string(reason)
end
# Finish Configuration https://wiki.vg/Protocol#Finish_Configuration
def serialize({:finish_configuration}) do
Write.varint(0x03)
end
# Clientbound Keep Alive https://wiki.vg/Protocol#Clientbound_Keep_Alive_(configuration)
def serialize({:clientbound_keep_alive, id}) do
Write.varint(0x04) <> <<id::64-big-signed>>
end
# Ping https://wiki.vg/Protocol#Ping_(configuration)
def serialize({:ping, id}) do
Write.varint(0x05) <> <<id::32-big-signed>>
end
# Reset Chat https://wiki.vg/Protocol#Reset_Chat
def serialize({:reset_chat}) do
Write.varint(0x06)
end
# Registry Data https://wiki.vg/Protocol#Registry_Data
def serialize({:registry_data, id, entries}) do
Write.varint(0x07) <> Write.string(id) <> Write.varint(length(entries)) <> Enum.map_join(entries, "", fn {name, nbt} ->
Write.string(name) <>
if nbt == nil do
Write.bool(false)
else
Write.bool(true) <> Amethyst.NBT.Write.write_net(nbt)
end
end)
end
# Remove Resource Pack https://wiki.vg/Protocol#Remove_Resource_Pack_(configuration)
def serialize({:remove_resource_pack, id}) do
Write.option(id, &Write.string/1)
end
# Add Resource Pack https://wiki.vg/Protocol#Add_Resource_Pack_(configuration)
def serialize({:add_resource_pack, id, url, hash, forced, msg}) do
Write.varint(0x09) <> Write.string(id) <> Write.string(url) <> Write.string(hash) <>
Write.bool(forced) <> Write.option(msg, &Write.string/1)
end
# Store Cookie https://wiki.vg/Protocol#Store_Cookie_(configuration)
def serialize({:store_cookie, id, data}) do
Write.varint(0x0A) <> Write.string(id) <> Write.string(data)
end
# Transfer https://wiki.vg/Protocol#Transfer_(configuration)
def serialize({:transfer, addr, port}) do
Write.varint(0x0B) <> Write.string(addr) <> Write.varint(port)
end
# Feature Flags https://wiki.vg/Protocol#Feature_Flags
def serialize({:feature_flags, flags}) do
Write.varint(0x0C) <> Write.varint(length(flags)) <> Write.list(flags, &Write.string/1)
end
# Update Tags https://wiki.vg/Protocol#Update_Tags
def serialize({:update_tags, tags}) do
Write.varint(0x0D) <> Write.varint(length(tags)) <>
Enum.reduce(tags, "", &serialize_tag/2)
end
# Clientbound Known Packs https://wiki.vg/Protocol#Clientbound_Known_Packs
def serialize({:clientbound_known_packs, packs}) do
Write.varint(0x0E) <> Write.varint(length(packs)) <>
Write.list(packs, fn {namespace, id, version} -> Write.string(namespace) <> Write.string(id) <> Write.string(version) end)
end
# Custom Report Details https://wiki.vg/Protocol#Custom_Report_Details
def serialize({:custom_report_details, details}) do
Write.varint(0x0F) <> Write.varint(length(details)) <>
Write.list(details, fn {id, data} -> Write.string(id) <> Write.string(data) end)
end
# Server Links https://wiki.vg/Protocol#Server_Links_(configuration)
def serialize({:server_links, links}) do
Write.varint(0x10) <> Write.varint(length(links)) <>
Write.list(links, fn {label, url} -> serialize_link_label(label) <> Write.string(url) end)
end
def serialize(packet) do
raise ArgumentError, "Tried serializing unknown packet #{inspect(packet)}"
end
defp serialize_tag({id, elements}, acc) do
acc <> Write.string(id) <> Write.varint(length(elements)) <> serialize_elements(elements)
end
defp serialize_elements(elements) do
Write.list(elements, fn {id, ids} ->
Write.string(id) <> Write.varint(length(ids)) <>
Write.list(ids, &Write.varint/1)
end)
end
defp serialize_link_label(:bug_report) do
<<0x01>> <> Write.varint(0x00)
end
defp serialize_link_label(:community_guidelines) do
<<0x01>> <> Write.varint(0x01)
end
defp serialize_link_label(:support) do
<<0x01>> <> Write.varint(0x02)
end
defp serialize_link_label(:status) do
<<0x01>> <> Write.varint(0x03)
end
defp serialize_link_label(:feedback) do
<<0x01>> <> Write.varint(0x04)
end
defp serialize_link_label(:community) do
<<0x01>> <> Write.varint(0x05)
end
defp serialize_link_label(:website) do
<<0x01>> <> Write.varint(0x06)
end
defp serialize_link_label(:forums) do
<<0x01>> <> Write.varint(0x07)
end
defp serialize_link_label(:news) do
<<0x01>> <> Write.varint(0x08)
end
defp serialize_link_label(:announcements) do
<<0x01>> <> Write.varint(0x09)
end
defp serialize_link_label(other) do
<<0x00>> <> Write.string(other)
end
## HANDLING
@impl true
# Client Information https://wiki.vg/Protocol#Client_Information
def handle({:client_information, locale, v_dist, chat_mode, chat_colors, displayed_skin_parts, main_hand, text_filtering, allow_listing}, client, state) do
state = state |> Keyword.put(:locale, locale) |> Keyword.put(:view_dist, v_dist) |> Keyword.put(:chat_mode, chat_mode) |>
Keyword.put(:chat_colors, chat_colors) |> Keyword.put(:displayed_skin_parts, displayed_skin_parts) |> Keyword.put(:main_hand, main_hand) |>
Keyword.put(:text_filtering, text_filtering) |> Keyword.put(:allow_listing, allow_listing)
# TODO: Here we should create the game handling task for this player and give it
# this data.
transmit({:clientbound_plugin_message, "minecraft:brand", Write.string("amethyst")}, client)
transmit({:clientbound_known_packs, [{"minecraft", "core", "1.21"}, {"minecraft", "dimension_type", "1.21"}]}, client)
{:ok, state}
end
# Serverbound Known Packs https://wiki.vg/Protocol#Serverbound_Known_Packs
def handle({:serverbound_known_packs, _packs}, client, state) do
# L + ratio + don't care + didn't ask + finish configuration
import Amethyst.NBT.Write
# TODO: This shouldn't be hard-coded but we obviously don't know what we will need until we have game handling
# This can at least be followed as a "minimum" of what we need to send for the client not to complain
transmit({:registry_data, "minecraft:dimension_type", [{"amethyst:basic", compound(%{
"has_skylight" => byte(1),
"has_ceiling" => byte(0),
"ultrawarm" => byte(0),
"natural" => byte(1),
"coordinate_scale" => float(1.0),
"bed_works" => byte(1),
"respawn_anchor_works" => byte(1),
"min_y" => int(0),
"height" => int(256),
"logical_height" => int(256),
"infiniburn" => string("#"),
"effects" => string("minecraft:overworld"),
"ambient_light" => float(0.0),
"piglin_safe" => byte(0),
"has_raids" => byte(1),
"monster_spawn_light_level" => int(0),
"monster_spawn_block_light_limit" => int(0)
})}]}, client)
transmit({:registry_data, "minecraft:painting_variant", [{"minecraft:kebab", compound(%{
"asset_id" => string("minecraft:kebab"),
"height" => int(1),
"width" => int(1),
})}]}, client)
transmit({:registry_data, "minecraft:wolf_variant", [{"minecraft:wolf_ashen", compound(%{
"wild_texture" => string("minecraft:entity/wolf/wolf_ashen"),
"tame_texture" => string("minecraft:entity/wolf/wolf_ashen_tame"),
"angry_texture" => string("minecraft:entity/wolf/wolf_ashen_angry"),
"biomes" => string("amethyst:basic"),
})}]}, client)
# https://gist.github.com/WinX64/ab8c7a8df797c273b32d3a3b66522906 minecraft:plains
basic_biome = compound(%{
"effects" => compound(%{
"sky_color" => int(7907327),
"water_fog_color" => int(329011),
"fog_color" => int(12638463),
"water_color" => int(4159204),
"mood_sound" => compound(%{
"tick_delay" => int(6000),
"offset" => float(2.0),
"sound" => string("minecraft:ambient.cave"),
"block_search_extent" => int(8)
}),
}),
"has_precipitation" => byte(1),
"temperature" => float(0.8),
"downfall" => float(0.4),
})
transmit({:registry_data, "minecraft:worldgen/biome", [
{"amethyst:basic", basic_biome}, {"minecraft:plains", basic_biome}
]}, client)
# holy fucking shit
generic_damage = compound(%{
"scaling" => string("when_caused_by_living_non_player"),
"exhaustion" => float(0.0),
"message_id" => string("generic")
})
transmit({:registry_data, "minecraft:damage_type", [
{"minecraft:in_fire", generic_damage}, {"minecraft:campfire", generic_damage}, {"minecraft:lightning_bolt", generic_damage},
{"minecraft:on_fire", generic_damage}, {"minecraft:lava", generic_damage}, {"minecraft:hot_floor", generic_damage},
{"minecraft:in_wall", generic_damage}, {"minecraft:cramming", generic_damage}, {"minecraft:drown", generic_damage},
{"minecraft:starve", generic_damage}, {"minecraft:cactus", generic_damage}, {"minecraft:fall", generic_damage},
{"minecraft:fly_into_wall", generic_damage}, {"minecraft:out_of_world", generic_damage}, {"minecraft:generic", generic_damage},
{"minecraft:magic", generic_damage}, {"minecraft:wither", generic_damage}, {"minecraft:dragon_breath", generic_damage},
{"minecraft:dry_out", generic_damage}, {"minecraft:sweet_berry_bush", generic_damage}, {"minecraft:freeze", generic_damage},
{"minecraft:stalagmite", generic_damage}, {"minecraft:outside_border", generic_damage}, {"minecraft:generic_kill", generic_damage},
]}, client)
transmit({:finish_configuration}, client)
{:ok, state}
end
# Acknowledge Finish Configuration https://wiki.vg/Protocol#Acknowledge_Finish_Configuration
def handle({:acknowledge_finish_configuration}, client, state) do
# TODO: All of this stuff should obviously not be hardcoded here
Amethyst.Server.Play.transmit({:login,
0, false, ["minecraft:overworld"], 0, 16, 16, false, true, true, 0,
"minecraft:overworld", <<0::64>>, :spectator, nil, false, true, nil, 0, false
}, client)
Amethyst.Server.Play.serve(client, state)
end
# Serverbound Plugin Message https://wiki.vg/Protocol#Serverbound_Plugin_Message_(configuration)
def handle({:serverbound_plugin_message, channel, data}, client, state) do
handle_plugin_message(channel, data, client, state)
end
def handle(tuple, state) do
Logger.error("Unhandled but known packet #{elem(tuple, 0)}")
{:unhandled, state}
end
defp handle_plugin_message("minecraft:brand", data, _client, state) do
{[brand], ""} = Read.start(data) |> Read.string |> Read.stop
Logger.info("Client using brand: #{brand}")
{:ok, Keyword.put(state, :brand, brand)}
end
defp handle_plugin_message("amethyst:hello", _data, client, state) do
Logger.info("Client is Amethyst aware! Hello!")
transmit({:clientbound_plugin_message, "amethyst:hello", ""}, client)
{:ok, Keyword.put(state, :knows_amethyst, true)}
end
end

View File

@ -0,0 +1,87 @@
# Amethyst - An experimental Minecraft server written in Elixir.
# Copyright (C) 2024 KodiCraft
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
defmodule Amethyst.Server.Generic do
@moduledoc """
This module includes generic logic which may be used by all stages of the server, including,
for instance, listening for packets.
"""
alias Amethyst.Minecraft.Read
def get_packet(client) do
{[length], ""} = get_varint(client, "")
recv = :gen_tcp.recv(client, length)
case recv do
{:ok, full_packet} -> ({[id], data} = Read.start(full_packet) |> Read.varint() |> Read.stop()
{id, data})
{:error, :closed} -> raise RuntimeError, "TODO: Handle disconnections reasonably"
{:error, error} -> raise RuntimeError, "An error has occured while waiting on a packet: #{error}"
end
end
defp get_varint(client, acc) do
{:ok, byte} = :gen_tcp.recv(client, 1)
case byte do
<<0::1, _::7>> -> Read.start(acc <> byte) |> Read.varint() |> Read.stop()
<<1::1, _::7>> -> get_varint(client, acc <> byte)
end
end
end
defmodule Amethyst.Server do
@moduledoc """
This module includes shared boilerplate code for all stages of the server.
"""
require Logger
@callback init(any()) :: any()
@callback deserialize(integer(), binary()) :: any()
@callback serialize(any()) :: binary()
@callback handle(any(), :gen_tcp.socket(), any()) :: {:ok, any()} | {:unhandled, any()}
defmacro __using__(_opts) do
quote do
@behaviour Amethyst.Server
@spec serve(:gen_tcp.socket(), any()) :: no_return()
def serve(client, state \\ []) do
Logger.debug("#{__MODULE__} serving client #{inspect(client)}")
serve_loop(client, init(state))
end
defp serve_loop(client, state) do
{id, data} = Amethyst.Server.Generic.get_packet(client)
Logger.debug("State: #{inspect(state)}")
packet = deserialize(id, data)
Logger.debug("Got packet #{inspect(packet)}")
{result, state} = handle(packet, client, state)
if result != :ok do
Logger.warning("Handler returned result #{result}")
end
serve_loop(client, state)
end
def transmit(packet, client) do
Logger.debug("Transmitting #{inspect(packet)}")
data = serialize(packet)
length = byte_size(data) |> Amethyst.Minecraft.Write.varint()
Logger.debug("Sending #{inspect(length <> data)}")
:gen_tcp.send(client, length <> data)
end
end
end
end

View File

@ -0,0 +1,72 @@
# Amethyst - An experimental Minecraft server written in Elixir.
# Copyright (C) 2024 KodiCraft
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
defmodule Amethyst.Server.Handshake do
@moduledoc """
This module contains the logic for the Handshake stage of the server.
"""
require Logger
use Amethyst.Server
alias Amethyst.Minecraft.Read
@impl true
def init(state) do
state
end
## DESERIALIZATION
@impl true
# Handshake https://wiki.vg/Protocol#Handshake
@spec deserialize(0, binary()) ::
{:handshake, any(), any(), any(), :login | :status | :transfer}
def deserialize(0x00, <<data::binary>>) do
{[ver, addr, port, next], ""} = Read.start(data) |> Read.varint() |> Read.string() |> Read.ushort() |> Read.varint() |> Read.stop()
next = case next do
1 -> :status
2 -> :login
3 -> :transfer
_ -> raise RuntimeError, "Client requested moving to an unknown state!"
end
{:handshake, ver, addr, port, next}
end
def deserialize(type, _) do
raise RuntimeError, "Got unknown packet type #{type}!"
end
## SERIALIZATION
@impl true
def serialize(_) do
raise RuntimeError, "No packets can be transmitted while still in the handshake stage!"
end
## HANDLING
@impl true
# Handshake https://wiki.vg/Protocol#Handshake
@spec handle(any(), any(), any()) :: no_return()
def handle({:handshake, 767, addr, port, next}, client, state) do
Logger.info("Got handshake, version 767 on #{addr}:#{port}. Wants to move to #{next}")
case next do
:status -> Amethyst.Server.Status.serve(client, state)
:login -> Amethyst.Server.Login.serve(client, state)
_ -> raise RuntimeError, "Unhandled move to next mode #{next}"
end
end
def handle(tuple, _, state) do
Logger.error("Unhandled but known packet #{elem(tuple, 0)}")
{:unhandled, state}
end
end

View File

@ -0,0 +1,136 @@
# Amethyst - An experimental Minecraft server written in Elixir.
# Copyright (C) 2024 KodiCraft
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
defmodule Amethyst.Server.Login do
@moduledoc """
This module contains the logic for the Login stage of the server.
"""
require Logger
use Amethyst.Server
alias Amethyst.Minecraft.Read
alias Amethyst.Minecraft.Write
@impl true
def init(state) do
state
end
## DESERIALIZATION
@impl true
# Login Start https://wiki.vg/Protocol#Login_Start
def deserialize(0x00, data) do
{[name, uuid], ""} = Read.start(data) |> Read.string() |> Read.uuid() |> Read.stop()
{:login_start, name, uuid}
end
# Encryption Response https://wiki.vg/Protocol#Encryption_Response
def deserialize(0x01, data) do
{[secret_length], rest} = Read.start(data) |> Read.varint() |> Read.stop()
{[secret, verify_token_length], rest} = Read.start(rest) |> Read.raw(secret_length) |> Read.varint() |> Read.stop()
{[verify_token], ""} = Read.start(rest) |> Read.raw(verify_token_length) |> Read.stop()
{:encryption_response, secret, verify_token}
end
# Login Plugin Response https://wiki.vg/Protocol#Login_Plugin_Response
def deserialize(0x02, data) do
{[message_id, success], rest} = Read.start(data) |> Read.varint() |> Read.bool() |> Read.stop()
if success do
{:login_plugin_response, message_id, rest}
else
{:login_plugin_response, message_id, nil}
end
end
# Login Acknowledged https://wiki.vg/Protocol#Login_Acknowledged
def deserialize(0x03, "") do
{:login_acknowledged}
end
# Cookie Response https://wiki.vg/Protocol#Cookie_Response_(login)
def deserialize(0x04, data) do
{[key, exists], rest} = Read.start(data) |> Read.string() |> Read.bool() |> Read.stop()
if exists do
{[length], rest} = Read.start(rest) |> Read.varint() |> Read.stop()
{[data], _} = Read.start(rest) |> Read.raw(length) |> Read.stop()
{:cookie_response, key, data}
else
{:cookie_response, key, nil}
end
end
def deserialize(type, _) do
raise RuntimeError, "Got unknown packet type #{type}!"
end
## SERIALIZATION
@impl true
# Disconnect (login) https://wiki.vg/Protocol#Disconnect_(login)
def serialize({:disconnect, reason}) do
Write.varint(0x00) <> Write.string(reason)
end
# Encryption Request https://wiki.vg/Protocol#Encryption_Request
def serialize({:encryption_request, server_id, pubkey, verify_token, auth}) do
Write.varint(0x01) <>
Write.string(server_id) <>
Write.varint(byte_size(pubkey)) <> pubkey <>
Write.varint(byte_size(verify_token)) <> verify_token <>
Write.bool(auth)
end
# Login Success https://wiki.vg/Protocol#Login_Success
def serialize({:login_success, uuid, username, props, strict}) do
Write.varint(0x02) <> Write.uuid(uuid) <> Write.string(username) <> Write.varint(length(props)) <>
Enum.reduce(props, "", fn {name, value, signature}, acc -> acc <> Write.string(name) <> Write.string(value) <> case signature do
nil -> <<0x00>>
signature -> <<0x01>> <> Write.string(signature)
end end) <> Write.bool(strict)
end
# Set Compression https://wiki.vg/Protocol#Set_Compression
def serialize({:set_compression, threshold}) do
Write.varint(0x03) <> Write.varint(threshold)
end
# Login Plugin Request https://wiki.vg/Protocol#Login_Plugin_Request
def serialize({:login_plugin_request, id, channel, data}) do
Write.varint(0x04) <> Write.varint(id) <> Write.string(channel) <> data
end
# Cookie Request (login) https://wiki.vg/Protocol#Cookie_Request_(login)
def serialize({:cookie_request_login, id}) do
Write.varint(0x05) <> Write.string(id)
end
def serialize(packet) do
raise ArgumentError, "Tried serializing unknown packet #{inspect(packet)}"
end
## HANDLING
@impl true
# Login Start https://wiki.vg/Protocol#Login_Start
def handle({:login_start, name, uuid}, client, state) do
Logger.info("Logging in #{name} (#{uuid})")
if Application.fetch_env!(:amethyst, :encryption) do
raise RuntimeError, "Encryption is currently unsupported." # TODO: Implement encryption
# verify_token = :crypto.strong_rand_bytes(4)
# pubkey = Amethyst.Keys.get_pub()
# auth = Application.fetch_env!(:amethyst, :auth)
# transmit({:encryption_request, "amethyst", pubkey, verify_token, auth}, client) # This is broken for some reason? java.lang.IllegalStateException: Protocol Error
else
transmit({:login_success, uuid, name, [], false}, client)
end
{:ok, state}
end
def handle({:login_acknowledged}, client, state) do
Amethyst.Server.Configuration.serve(client, state)
{:ok, state}
end
def handle(tuple, _, state) do
Logger.error("Unhandled but known packet #{elem(tuple, 0)}")
{:unhandled, state}
end
end

View File

@ -0,0 +1,97 @@
# Amethyst - An experimental Minecraft server written in Elixir.
# Copyright (C) 2024 KodiCraft
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
defmodule Amethyst.Server.Play do
@moduledoc """
This module contains the logic for the Play stage of the server.
"""
require Logger
use Amethyst.Server
alias Amethyst.Minecraft.Read
alias Amethyst.Minecraft.Write
@impl true
def init(state) do
state
end
## DESERIALIZATION
@impl true
def deserialize(0x12, data) do
{[channel], data} = Read.start(data) |> Read.string |> Read.stop
{:serverbound_plugin_message, channel, data}
end
def deserialize(type, _) do
raise RuntimeError, "Got unknown packet type #{type}!"
end
## SERIALIZATION
@impl true
# Login https://wiki.vg/Protocol#Login_(play)
def serialize({:login, eid, hardcore, dimensions,
max_players, view_distance, simulation_distance,
reduce_debug, enable_respawn_screen, limited_crafting,
dim_type, dim_name, hashed_seed, gamemode, prev_gm,
is_debug, is_flat, death_loc, portal_cooldown, enforce_chat}) when byte_size(hashed_seed) == 8 do
# TODO: This is a big unreadable slab of serialization which makes bugs really hard to catch, it needs a proper rework at some point
Write.varint(0x2B) <>
Write.int(eid) <>
Write.bool(hardcore) <>
Write.varint(length(dimensions)) <>
Write.list(dimensions, &Write.string/1) <>
Write.varint(max_players) <>
Write.varint(view_distance) <>
Write.varint(simulation_distance) <>
Write.bool(reduce_debug) <>
Write.bool(enable_respawn_screen) <>
Write.bool(limited_crafting) <>
Write.varint(dim_type) <>
Write.string(dim_name) <>
hashed_seed <>
Write.ubyte(gamemode_id(gamemode)) <>
Write.byte(gamemode_id(prev_gm)) <>
Write.bool(is_debug) <>
Write.bool(is_flat) <>
if(death_loc == nil, do: Write.bool(false), else: Write.bool(true) <> Write.string(elem(death_loc, 0)) <> Write.position(elem(death_loc, 1))) <>
Write.varint(portal_cooldown) <>
Write.bool(enforce_chat)
end
def serialize(packet) do
raise ArgumentError, "Tried serializing unknown packet #{inspect(packet)}"
end
## HANDLING
@impl true
def handle({:serverbound_plugin_message, channel, data}, _client, state) do
Logger.debug("Got plugin message #{channel} with data #{inspect(data)}")
{:ok, state}
end
def handle(tuple, _, state) do
Logger.error("Unhandled but known packet #{elem(tuple, 0)}")
{:unhandled, state}
end
defp gamemode_id(gm) do
case gm do
nil -> -1
:survival -> 0
:creative -> 1
:adventure -> 2
:spectator -> 3
end
end
end

View File

@ -0,0 +1,85 @@
# Amethyst - An experimental Minecraft server written in Elixir.
# Copyright (C) 2024 KodiCraft
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
defmodule Amethyst.Server.Status do
@moduledoc """
This module contains the logic for the Status stage of the server.
"""
require Logger
use Amethyst.Server
alias Amethyst.Minecraft.Read
alias Amethyst.Minecraft.Write
@impl true
def init(state) do
state
end
## DESERIALIZATION
@impl true
# Status Request https://wiki.vg/Protocol#Status_Request
def deserialize(0x00, _) do
{:status_request}
end
# Ping Request https://wiki.vg/Protocol#Ping_Request
def deserialize(0x01, <<data::binary>>) do
{[payload], ""} = Read.start(data) |> Read.long() |> Read.stop()
{:ping_request, payload}
end
def deserialize(type, _) do
raise RuntimeError, "Got unknown packet type #{type}!"
end
## SERIALIZATION
@impl true
# Status Response https://wiki.vg/Protocol#Status_Response
def serialize({:status_response, data}) do
Write.varint(0x00) <> Write.string(data)
end
def serialize({:ping_response, payload}) do
Write.varint(0x01) <> Write.long(payload)
end
def serialize(packet) do
raise ArgumentError, "Tried serializing unknown packet #{inspect(packet)}"
end
## HANDLING
@impl true
# Status Request https://wiki.vg/Protocol#Status_Request
def handle({:status_request}, client, state) do
# We want to make this more dynamic in the future, but this works for now
packet = {:status_response, ~s({
"version": {"name": "1.21", "protocol": 767},
"players": {"max": -1, "online": 69, "sample": [{"name": "§dAmethyst§r", "id": "4566e69f-c907-48ee-8d71-d7ba5aa00d20"}]},
"description": {"text":"Amethyst is an experimental server written in Elixir"},
"enforcesSecureChat": false,
"previewsChat": false,
"preventsChatReports": true
})}
transmit(packet, client)
{:ok, state}
end
def handle({:ping_request, payload}, client, state) do
packet = {:ping_response, payload}
transmit(packet, client)
{:ok, state}
end
def handle(tuple, _, state) do
Logger.error("Unhandled but known packet #{elem(tuple, 0)}")
{:unhandled, state}
end
end

View File

@ -1,326 +0,0 @@
# Amethyst - An experimental Minecraft server written in Elixir.
# Copyright (C) 2024 KodiCraft
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
defmodule Amethyst.ConnectionState.Configuration do
require Amethyst.ConnectionState.Macros
alias Amethyst.ConnectionState.Macros
require Logger
@moduledoc """
This module contains the packets and logic for the Configuration state.
"""
Macros.defpacket_clientbound :cookie_request, 0x00, 767, [identifier: :string]
Macros.defpacket_clientbound :clientbound_plugin_message, 0x01, 767, [channel: :string, data: :raw]
Macros.defpacket_clientbound :disconnect, 0x02, 767, [reason: :nbt]
Macros.defpacket_clientbound :finish_configuration, 0x03, 767, []
Macros.defpacket_clientbound :clientbound_keep_alive, 0x04, 767, [id: :long]
Macros.defpacket_clientbound :ping, 0x05, 767, [id: :int]
Macros.defpacket_clientbound :reset_chat, 0x06, 767, []
Macros.defpacket_clientbound :registry_data, 0x07, 767, [
id: :string,
entries: {:array, [
id: :string,
data: {:optional, :nbt}
]}
]
Macros.defpacket_clientbound :remove_resource_pack, 0x08, 767, [
uuid: {:optional, :uuid}
]
Macros.defpacket_clientbound :add_resource_pack, 0x09, 767, [
uuid: :uuid,
url: :string,
hash: :string,
forced: :bool,
prompt_message: {:optional, :string}
]
Macros.defpacket_clientbound :store_cookie, 0x0A, 767, [identifier: :string, payload: :byte_array]
Macros.defpacket_clientbound :transfer, 0x0B, 767, [host: :string, port: :varint]
Macros.defpacket_clientbound :feature_flags, 0x0C, 767, [flags: {:array, [flag: :string]}]
Macros.defpacket_clientbound :update_tags, 0x0D, 767, [
tags: {:array, [
registry: :string,
tags: {:array, [
name: :string,
entries: {:array, [id: :varint]}
]}
]}
]
Macros.defpacket_clientbound :clientbound_known_packs, 0x0E, 767, [
packs: {:array, [
namespace: :string,
id: :string,
version: :string
]}
]
Macros.defpacket_clientbound :custom_report_details, 0x0F, 767, [
details: {:array, [
title: :string,
desctioption: :string
]}
]
Macros.defpacket_clientbound :server_links, 0x10, 767, [
links: {:array, [
is_builtin: :bool,
label: :string,
url: :string
]}
]
Macros.defpacket_serverbound :client_information, 0x00, 767, [
locale: :string,
view_distance: :byte,
chat_mode: :varint,
chat_colors: :bool,
displayed_skin_parts: :byte,
main_hand: :varint,
text_filtering: :bool,
allow_server_listings: :bool
]
Macros.defpacket_serverbound :cookie_response, 0x01, 767, [
key: :string,
payload: {:optional, :byte_array}
]
Macros.defpacket_serverbound :serverbound_plugin_message, 0x02, 767, [channel: :string, data: :raw]
Macros.defpacket_serverbound :acknowledge_finish_configuration, 0x03, 767, []
Macros.defpacket_serverbound :serverbound_keep_alive, 0x04, 767, [id: :long]
Macros.defpacket_serverbound :pong, 0x05, 767, [id: :int]
Macros.defpacket_serverbound :resource_pack_response, 0x06, 767, [uuid: :uuid, result: :varint]
Macros.defpacket_serverbound :serverbound_known_packs, 0x07, 767, [
packs: {:array, [
namespace: :string,
id: :string,
version: :string
]}
]
def handle(%{packet_type: :serverbound_plugin_message, channel: "minecraft:brand", data: data}, 767, state) do
{[string], ""} = Amethyst.Minecraft.Read.start(data) |> Amethyst.Minecraft.Read.string() |> Amethyst.Minecraft.Read.stop()
Logger.debug("Received brand: #{string}")
send(self(), {:send_packet, %{
packet_type: :clientbound_plugin_message,
channel: "minecraft:brand",
data: Amethyst.Minecraft.Write.string("Amethyst")
}})
state |> Map.put(:brand, string)
end
def handle(%{
packet_type: :client_information,
locale: locale,
view_distance: view_distance,
chat_mode: chat_mode,
chat_colors: chat_colors,
displayed_skin_parts: displayed_skin_parts,
main_hand: main_hand,
text_filtering: text_filtering,
allow_server_listings: allow_server_listings
}, 767, state) do
Logger.debug("Received client information")
send(self(), {:send_packet, %{
packet_type: :feature_flags,
flags: []
}})
send(self(), {:send_packet, %{
packet_type: :clientbound_known_packs,
packs: [%{namespace: "minecraft", id: "base", version: "1.21"}]
}})
state
|> Map.put(:locale, locale)
|> Map.put(:view_distance, view_distance)
|> Map.put(:chat_mode, chat_mode)
|> Map.put(:chat_colors, chat_colors)
|> Map.put(:displayed_skin_parts, displayed_skin_parts)
|> Map.put(:main_hand, main_hand)
|> Map.put(:text_filtering, text_filtering)
|> Map.put(:allow_server_listings, allow_server_listings)
end
def handle(%{packet_type: :serverbound_known_packs, packs: _packs}, 767, _state) do
Logger.debug("Received known packs")
import Amethyst.NBT.Write
# TODO: Of course, the registries shouldn't be hard-coded
send(self(), {:send_packet, %{
packet_type: :registry_data,
id: "minecraft:dimension_type",
entries: [
%{id: "amethyst:basic", data: compound(%{
"has_skylight" => byte(1),
"has_ceiling" => byte(0),
"ultrawarm" => byte(0),
"natural" => byte(1),
"coordinate_scale" => float(1.0),
"bed_works" => byte(1),
"respawn_anchor_works" => byte(1),
"min_y" => int(0),
"height" => int(256),
"logical_height" => int(256),
"infiniburn" => string("#"),
"effects" => string("minecraft:overworld"),
"ambient_light" => float(0.0),
"piglin_safe" => byte(0),
"has_raids" => byte(1),
"monster_spawn_light_level" => int(0),
"monster_spawn_block_light_limit" => int(0)
})}
]
}})
send(self(), {:send_packet, %{
packet_type: :registry_data,
id: "minecraft:painting_variant", entries: [
%{id: "minecraft:kebab", data: compound(%{
"asset_id" => string("minecraft:kebab"),
"height" => int(1),
"width" => int(1),
})}
]
}})
send(self(), {:send_packet, %{
packet_type: :registry_data,
id: "minecraft:wolf_variant",
entries: [
%{id: "minecraft:wolf_ashen", data: compound(%{
"wild_texture" => string("minecraft:entity/wolf/wolf_ashen"),
"tame_texture" => string("minecraft:entity/wolf/wolf_ashen_tame"),
"angry_texture" => string("minecraft:entity/wolf/wolf_ashen_angry"),
"biomes" => string("amethyst:basic"),
})}
]
}})
# https://gist.github.com/WinX64/ab8c7a8df797c273b32d3a3b66522906 minecraft:plains
basic_biome = compound(%{
"effects" => compound(%{
"sky_color" => int(7907327),
"water_fog_color" => int(329011),
"fog_color" => int(12638463),
"water_color" => int(4159204),
"mood_sound" => compound(%{
"tick_delay" => int(6000),
"offset" => float(2.0),
"sound" => string("minecraft:ambient.cave"),
"block_search_extent" => int(8)
}),
}),
"has_precipitation" => byte(1),
"temperature" => float(0.8),
"downfall" => float(0.4),
})
send(self(), {:send_packet, %{
packet_type: :registry_data,
id: "minecraft:worldgen/biome",
entries: [
%{id: "amethyst:basic", data: basic_biome},
%{id: "minecraft:plains", data: basic_biome}
]
}})
# this game sucks
generic_damage = compound(%{
"scaling" => string("when_caused_by_living_non_player"),
"exhaustion" => float(0.0),
"message_id" => string("generic")
})
send(self(), {:send_packet, %{
packet_type: :registry_data,
id: "minecraft:damage_type",
entries: [
%{id: "minecraft:in_fire", data: generic_damage},
%{id: "minecraft:campfire", data: generic_damage},
%{id: "minecraft:lightning_bolt", data: generic_damage},
%{id: "minecraft:on_fire", data: generic_damage},
%{id: "minecraft:lava", data: generic_damage},
%{id: "minecraft:cramming", data: generic_damage},
%{id: "minecraft:drown", data: generic_damage},
%{id: "minecraft:starve", data: generic_damage},
%{id: "minecraft:cactus", data: generic_damage},
%{id: "minecraft:fall", data: generic_damage},
%{id: "minecraft:fly_into_wall", data: generic_damage},
%{id: "minecraft:out_of_world", data: generic_damage},
%{id: "minecraft:generic", data: generic_damage},
%{id: "minecraft:magic", data: generic_damage},
%{id: "minecraft:wither", data: generic_damage},
%{id: "minecraft:dragon_breath", data: generic_damage},
%{id: "minecraft:dry_out", data: generic_damage},
%{id: "minecraft:sweet_berry_bush", data: generic_damage},
%{id: "minecraft:freeze", data: generic_damage},
%{id: "minecraft:stalagmite", data: generic_damage},
%{id: "minecraft:outside_border", data: generic_damage},
%{id: "minecraft:generic_kill", data: generic_damage},
%{id: "minecraft:hot_floor", data: generic_damage},
%{id: "minecraft:in_wall", data: generic_damage},
]
}})
send(self(), {:send_packet, %{packet_type: :finish_configuration}})
:ok
end
def handle(%{packet_type: :acknowledge_finish_configuration}, 767, state) do
Logger.debug("Received acknowledge finish configuration")
send(self(), {:set_state, Amethyst.ConnectionState.Play})
game = Application.fetch_env!(:amethyst, :default_game) |> Amethyst.GameCoordinator.find_or_create()
state = state |> Map.put(:game, game)
login = Amethyst.Game.login(game, state)
case login do
:reject ->
send(self(), {:disconnect, "Default game rejected connection"})
:ok
{:accept, {x, y, z}, {yaw, pitch}} ->
send(self(), {:send_packet, %{
packet_type: :login,
entity_id: 0,
is_hardcore: false,
dimensions: [%{name: "minecraft:overworld"}],
max_players: 0,
view_distance: 16,
simulation_distance: 16,
reduced_debug_info: false,
enable_respawn_screen: true,
do_limited_crafting: false,
dimension_type: 0,
dimension_name: "minecraft:overworld",
hashed_seed: 0,
game_mode: 1,
previous_game_mode: -1,
is_debug: false,
is_flat: false,
death_location: nil,
portal_cooldown: 0,
enforces_secure_chat: false
}})
send(self(), {:send_packet, %{
packet_type: :synchronize_player_position,
x: x, y: y, z: z, yaw: yaw, pitch: pitch, teleport_id: 0, flags: 0x00
}})
send(self(), {:send_packet, Amethyst.ConnectionState.Play.ge_start_waiting_for_level_chunks(767)})
send(self(), {:send_packet, %{packet_type: :set_center_chunk,
chunk_x: div(floor(x), 16),
chunk_z: div(floor(z), 16)
}})
send(self(), {:set_position, {x, y, z}})
# Begin keepalive loop
# TODO: Put it under some supervisor
me = self()
pid = spawn(fn -> Amethyst.ConnectionState.Play.keepalive_loop(me) end)
state |> Map.put(:keepalive, pid)
end
end
def disconnect(reason) do
%{packet_type: :disconnect, reason: {:compound, %{
"text" => {:string, reason}
}}}
end
end

View File

@ -1,56 +0,0 @@
# Amethyst - An experimental Minecraft server written in Elixir.
# Copyright (C) 2024 KodiCraft
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
defmodule Amethyst.ConnectionState.Handshake do
require Amethyst.ConnectionState.Macros
alias Amethyst.ConnectionState.Macros
require Logger
@moduledoc """
This module contains the packets and logic for the Handshake state.
"""
# Notice that in the Handshake state, our version is always 0.
Macros.defpacket_serverbound :handshake, 0x00, 0, [
version: :varint,
address: :string,
port: :ushort,
next: :varint
]
def handle(%{packet_type: :handshake, version: 767, address: address, port: port, next: next}, 0, _state) do
Logger.debug("Received handshake for #{address}:#{port} with version 767")
case next do
1 ->
send(self(), {:set_state, Amethyst.ConnectionState.Status})
send(self(), {:set_version, 767})
:ok
2 ->
send(self(), {:set_state, Amethyst.ConnectionState.Login})
send(self(), {:set_version, 767})
:ok
3 ->
send(self(), {:set_state, Amethyst.ConnectionState.Transfer})
send(self(), {:set_version, 767})
:ok
_ -> {:error, "Invalid next state"}
end
end
def disconnect(_reason) do
# When disconnecting from the handshake state, we can't send any sort of reason.
end
end

View File

@ -1,92 +0,0 @@
# Amethyst - An experimental Minecraft server written in Elixir.
# Copyright (C) 2024 KodiCraft
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
defmodule Amethyst.ConnectionState.Login do
require Amethyst.ConnectionState.Macros
alias Amethyst.ConnectionState.Macros
require Logger
@moduledoc """
This module contains the packets and logic for the Login state.
"""
Macros.defpacket_clientbound :disconnect, 0x00, 767, [reason: :json]
Macros.defpacket_clientbound :encryption_request, 0x01, 767, [
server_id: :string,
public_key: :byte_array,
verify_token: :byte_array,
should_authenticate: :bool
]
Macros.defpacket_clientbound :login_success, 0x02, 767, [
uuid: :uuid,
username: :string,
properties: {:array, [
name: :string,
value: :string,
signature: {:optional, :string}
]},
strict_error_handling: :bool
]
Macros.defpacket_clientbound :set_compression, 0x03, 767, [threshold: :varint]
Macros.defpacket_clientbound :login_plugin_request, 0x04, 767, [
message_id: :varint,
channel: :string,
data: :raw
]
Macros.defpacket_clientbound :cookie_request, 0x05, 767, [identifier: :string]
Macros.defpacket_serverbound :login_start, 0x00, 767, [name: :string, player_uuid: :uuid]
Macros.defpacket_serverbound :encryption_response, 0x01, 767, [
shared_secret: :byte_array,
verify_token: :byte_array
]
Macros.defpacket_serverbound :login_plugin_response, 0x02, 767, [
message_id: :varint,
data: {:optional, :raw}
]
Macros.defpacket_serverbound :login_acknowledged, 0x03, 767, []
Macros.defpacket_serverbound :cookie_response, 0x04, 767, [identifier: :string, payload: {:optional, :byte_array}]
def handle(%{packet_type: :login_start, name: name, player_uuid: player_uuid}, 767, _state) do
Logger.debug("Received login start for #{name} with UUID #{player_uuid}")
if Application.fetch_env!(:amethyst, :encryption) do
raise RuntimeError, "Encryption is not currently supported"
else
send(self(), {:send_packet, %{
packet_type: :login_success,
uuid: player_uuid,
username: name,
properties: [],
strict_error_handling: true
}})
:ok
end
end
def handle(%{packet_type: :login_acknowledged}, 767, _state) do
Logger.debug("Received login acknowledged")
send(self(), {:set_state, Amethyst.ConnectionState.Configuration})
:ok
end
def disconnect(reason) do
%{packet_type: :disconnect, reason:
%{
"text" => reason
}
}
end
end

View File

@ -1,160 +0,0 @@
# Amethyst - An experimental Minecraft server written in Elixir.
# Copyright (C) 2024 KodiCraft
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
defmodule Amethyst.ConnectionState.Macros do
require Logger
defmacro defpacket_serverbound(name, id, version, signature, where \\ true) do
quote do
def deserialize(unquote(id), unquote(version), data) when unquote(where) do
{packet, ""} = Amethyst.ConnectionState.Macros.read_signature(data, unquote(signature))
packet |> Map.put(:packet_type, unquote(name))
end
end
end
defmacro defpacket_clientbound(name, id, version, signature, where \\ true) do
quote do
def serialize(%{packet_type: unquote(name)} = packet, unquote(version)) when unquote(where) do
if Amethyst.ConnectionState.Macros.check_type(packet, unquote(signature)) do
Amethyst.Minecraft.Write.varint(unquote(id)) <> Amethyst.ConnectionState.Macros.write_signature(packet, unquote(signature))
else
raise "Invalid packet type for #{unquote(name)}! Got #{inspect(packet)}"
end
end
end
end
alias Amethyst.Minecraft.Read
alias Amethyst.Minecraft.Write
def read_signature(data, signature) do
names = Enum.map(signature, fn {name, _type} -> name end)
{got, rest} = Enum.reduce(signature, Read.start(data), fn {_name, type}, {acc, rest, :reversed} ->
case type do
{:optional, {:compound, signature}} ->
{[exists], rest} = Read.start(rest) |> Read.bool() |> Read.stop()
if exists do
{item, rest} = read_signature(rest, signature)
{[item | acc], rest, :reversed}
else
{[nil | acc], rest, :reversed}
end
{:optional, t} ->
{[exists], rest} = Read.start(rest) |> Read.bool() |> Read.stop()
if exists do
apply(Read, t, [{acc, rest, :reversed}])
else
{[nil | acc], rest, :reversed}
end
{:array, signature} ->
{[count], rest} = Read.start(rest) |> Read.varint() |> Read.stop()
if count == 0 do
{[[] | acc], rest, :reversed}
else
{items, rest} = Enum.reduce(1..count, {[], rest}, fn _, {acc, rest} ->
{item, rest} = read_signature(rest, signature)
{[item | acc], rest}
end)
{[Enum.reverse(items) | acc], rest, :reversed}
end
t -> apply(Read, t, [{acc, rest, :reversed}])
end
end) |> Read.stop()
{Enum.zip(names, got) |> Map.new(), rest}
end
def write_signature(packet, signature) do
Enum.reduce(signature, "", fn {name, type}, acc ->
case type do
{:optional, {:compound, signature}} ->
case Map.get(packet, name) do
nil -> acc <> Write.bool(false)
_ -> acc <> Write.bool(true) <> write_signature(Map.get(packet, name), signature)
end
{:optional, t} ->
case Map.get(packet, name) do
nil -> acc <> Write.bool(false)
_ -> acc <> Write.bool(true) <> apply(Write, t, [Map.get(packet, name)])
end
{:array, signature} ->
acc <> Write.varint(Enum.count(Map.get(packet, name))) <>
Enum.reduce(Map.get(packet, name), "", fn item, acc ->
acc <> write_signature(item, signature)
end)
{:literal, {type, value}} -> acc <> apply(Write, type, [value])
t -> acc <> apply(Write, t, [Map.get(packet, name)])
end
end)
end
def check_type(packet, signature) do
try do
Enum.all?(signature, fn {name, type} ->
case Map.get(packet, name, :missing) do
:missing ->
if elem(type, 0) == :literal do
true
else
throw {:missing, name}
end
value -> case type_matches(value, type) do
true -> true
false -> throw {:mismatch, name, value, type}
end
end
end)
catch
reason ->
Logger.debug("Found invalid packet type: #{inspect(reason)}")
false
end
end
def type_matches(value, :bool) when is_boolean(value), do: true
def type_matches(value, :byte) when is_integer(value) and value in -128..127, do: true
def type_matches(value, :ubyte) when is_integer(value) and value in 0..255, do: true
def type_matches(value, :short) when is_integer(value) and value in -32768..32767, do: true
def type_matches(value, :ushort) when is_integer(value) and value in 0..65535, do: true
def type_matches(value, :int) when is_integer(value) and value in -2147483648..2147483647, do: true
def type_matches(value, :long) when is_integer(value) and value in -9223372036854775808..9223372036854775807, do: true
def type_matches(value, :float) when is_number(value), do: true
def type_matches(value, :double) when is_number(value), do: true
def type_matches(value, :varint) when is_integer(value) and value in -2147483648..2147483647, do: true
def type_matches(value, :varlong) when is_integer(value) and value in -9223372036854775808..9223372036854775807, do: true
def type_matches(value, :uuid) when is_binary(value) and byte_size(value) == 36, do: true
def type_matches(value, :string) when is_binary(value), do: true
def type_matches(value, :raw) when is_binary(value), do: true
def type_matches(value, :byte_array) when is_binary(value), do: true
def type_matches({x, y, z}, :position) when
is_integer(x) and x in -33554432..33554431 and
is_integer(y) and y in -2048..2047 and
is_integer(z) and z in -33554432..33554431, do: true
def type_matches(value, :nbt), do: Amethyst.NBT.Write.check_type(value)
def type_matches(value, :json) do
case Jason.encode(value) do
{:ok, _} -> true
_ -> false
end
end
def type_matches(value, {:optional, _type}) when is_nil(value), do: true
def type_matches(value, {:optional, type}), do: type_matches(value, type)
def type_matches(value, {:array, signature}) when is_list(value), do: Enum.all?(value, fn item -> check_type(item, signature) end)
def type_matches(value, {:compound, signature}) when is_map(value), do: check_type(value, signature)
def type_matches(_, _) do
false
end
end

View File

@ -1,265 +0,0 @@
# Amethyst - An experimental Minecraft server written in Elixir.
# Copyright (C) 2024 KodiCraft
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
defmodule Amethyst.ConnectionState.Play do
require Amethyst.ConnectionState.Macros
alias Amethyst.ConnectionState.Macros
require Logger
@moduledoc """
This module contains the packets and logic for the Play state.
"""
Macros.defpacket_clientbound :disconnect, 0x1D, 767, [reason: :nbt]
Macros.defpacket_clientbound :keep_alive, 0x26, 767, [id: :long]
Macros.defpacket_clientbound :chunk_data_and_update_light, 0x27, 767, [
chunk_x: :int,
chunk_z: :int,
heightmaps: :nbt,
data: :byte_array,
block_entities: {:array, [
packed_xz: :ubyte, # TODO: This would be interesting to have in a clearer format
y: :short,
type: :varint,
data: :nbt
]},
sky_light_mask: :raw,
block_light_mask: :raw,
empty_sky_light_mask: :raw,
empty_block_light_mask: :raw,
sky_light_arrays: {:array, [
sky_light_array: :byte_array
]},
block_light_arrays: {:array, [
block_light_array: :byte_array
]}
]
Macros.defpacket_clientbound :login, 0x2B, 767, [
entity_id: :int,
is_hardcore: :bool,
dimensions: {:array, [name: :string]},
max_players: :varint,
view_distance: :varint,
simulation_distance: :varint,
reduced_debug_info: :bool,
enable_respawn_screen: :bool,
do_limited_crafting: :bool,
dimension_type: :varint,
dimension_name: :string,
hashed_seed: :long,
game_mode: :ubyte,
previous_game_mode: :byte,
is_debug: :bool,
is_flat: :bool,
death_location: {:optional, {:compound, [
dimension: :string,
location: :pos
]}},
portal_cooldown: :varint,
enforces_secure_chat: :bool,
]
Macros.defpacket_clientbound :player_info_update_add_player, 0x2E, 767, [
actions: {:literal, 0x01},
players: {:array, [
uuid: :uuid,
name: :string,
properties: {:array, [
name: :string,
value: :string,
signature: {:optional, :string},
]}
]}
]
Macros.defpacket_clientbound :player_info_update_initialize_chat, 0x2E, 767, [
actions: {:literal, 0x02},
players: {:array, [
uuid: :uuid,
data: {:optional, {:compound, [
chat_session_id: :uuid,
public_key_expiry_time: :long,
encoded_public_key: :byte_array,
public_key_signature: :byte_array
]}}
]}
]
Macros.defpacket_clientbound :player_info_update_update_game_mode, 0x2E, 767, [
actions: {:literal, 0x04},
players: {:array, [
uuid: :uuid,
gamemode: :varint
]}
]
Macros.defpacket_clientbound :player_info_update_update_listed, 0x2E, 767, [
actions: {:literal, 0x08},
players: {:array, [
uuid: :uuid,
listed: :bool
]}
]
Macros.defpacket_clientbound :player_info_update_update_latency, 0x2E, 767, [
actions: {:literal, 0x10},
players: {:array, [
uuid: :uuid,
ping: :varint, # Milliseconds
]}
]
Macros.defpacket_clientbound :player_info_update_update_display_name, 0x2E, 767, [
actions: {:literal, 0x20},
players: {:array, [
uuid: :uuid,
display_name: {:optional, :nbt}
]}
]
Macros.defpacket_clientbound :synchronize_player_position, 0x40, 767, [
x: :double,
y: :double,
z: :double,
yaw: :float,
pitch: :float,
flags: :byte,
teleport_id: :varint
]
Macros.defpacket_clientbound :set_center_chunk, 0x54, 767, [
chunk_x: :varint, chunk_z: :varint
]
Macros.defpacket_clientbound :game_event, 0x22, 767, [
event: :ubyte, value: :float
]
# We can use functions to wrap over this packet and make it a bit clearer.
# Taking the protocol version here makes it less portable but whatever, fuck this packet
def ge_no_respawn_block_available(767), do: %{packet_type: :game_event, event: 0, value: 0}
def ge_begin_raining(767), do: %{packet_type: :game_event, event: 1, value: 0}
def ge_end_raining(767), do: %{packet_type: :game_event, event: 2, value: 0}
def ge_change_game_mode(767, gm) when is_integer(gm), do: %{packet_type: :game_event, event: 3, value: gm}
def ge_win_game(767, credits?) when is_integer(credits?), do: %{packet_type: :game_event, event: 4, value: credits?}
def ge_game_event(767, event) when is_integer(event), do: %{packet_type: :game_event, event: 5, value: event}
def ge_arrow_hit_player(767), do: %{packet_type: :game_event, event: 6, value: 0}
def ge_rain_level_change(767, value) when is_number(value), do: %{packet_type: :game_event, event: 7, value: value}
def ge_thunder_level_change(767, value) when is_number(value), do: %{packet_type: :game_event, event: 8, value: value}
def ge_play_pufferfish_sting_sound(767), do: %{packet_type: :game_event, event: 9, value: 0}
def ge_play_elder_guardian_mob_appearance(767), do: %{packet_type: :game_event, event: 10, value: 0}
def ge_enable_respawn_screen(767, enabled?) when is_integer(enabled?), do: %{packet_type: :game_event, event: 11, value: enabled?}
def ge_limited_crafting(767, enabled?) when is_integer(enabled?), do: %{packet_type: :game_event, event: 12, value: enabled?}
def ge_start_waiting_for_level_chunks(767), do: %{packet_type: :game_event, event: 13, value: 0}
Macros.defpacket_serverbound :confirm_teleportation, 0x00, 767, [teleport_id: :varint]
Macros.defpacket_serverbound :serverbound_plugin_message, 0x12, 767, [channel: :string, data: :raw]
Macros.defpacket_serverbound :keep_alive, 0x18, 767, [id: :long]
Macros.defpacket_serverbound :set_player_position, 0x1A, 767, [
x: :double,
feet_y: :double,
z: :double,
on_ground: :bool
]
Macros.defpacket_serverbound :set_player_position_and_rotation, 0x1B, 767, [
x: :double,
feet_y: :double,
z: :double,
yaw: :float,
pitch: :float,
on_ground: :bool
]
Macros.defpacket_serverbound :set_player_rotation, 0x1C, 767, [
yaw: :float,
pitch: :float,
on_ground: :bool # I don't understand their obsession with this...
]
Macros.defpacket_serverbound :set_player_on_ground, 0x1D, 767, [on_ground: :bool]
Macros.defpacket_serverbound :player_command, 0x25, 767, [eid: :varint, action_id: :varint, jump_boost: :varint]
def handle(%{packet_type: :confirm_teleportation, teleport_id: id}, 767, state) do
Amethyst.Game.accept_teleport(state[:game], id)
:ok
end
def handle(%{packet_type: :set_player_position_and_rotation, x: x, feet_y: y, z: z, yaw: yaw, pitch: pitch, on_ground: _ground}, 767, state) do
# I don't know why we would ever trust on_ground here... the server computes that
Amethyst.Game.player_position(state[:game], {x, y, z})
Amethyst.Game.player_rotation(state[:game], {yaw, pitch})
:ok
end
def handle(%{packet_type: :serverbound_plugin_message, channel: channel, data: _}, 767, _state) do
Logger.debug("Got plugin message on #{channel}")
end
def handle(%{packet_type: :set_player_position, x: x, feet_y: y, z: z, on_ground: _ground}, 767, state) do
# I don't know why we would ever trust on_ground here... the server computes that
Amethyst.Game.player_position(state[:game], {x, y, z})
:ok
end
def handle(%{packet_type: :set_player_rotation, yaw: yaw, pitch: pitch, on_ground: _ground}, 767, state) do
# I don't know why we would ever trust on_ground here... the server computes that
Amethyst.Game.player_rotation(state[:game], {yaw, pitch})
:ok
end
def handle(%{packet_type: :set_player_on_ground, on_ground: _}, 767, _state) do
:ok # Again, don't trust the client for something we can compute
end
def handle(%{packet_type: :player_command, eid: _eid, action_id: aid, jump_boost: _horse_jump}, 767, _state) do
# TODO: Actually handle these events
case aid do
0 -> # Start sneaking
:ok
1 -> # Stop sneaking
:ok
2 -> # Leave bed
:ok
3 -> # Start sprinting
:ok
4 -> # Stop sprinting
:ok
5 -> # Start horse jump
:ok
6 -> # Stop horse jump
:ok
7 -> # Open vehicle inventory
:ok
8 -> # Start elytra flying
:ok
_ -> raise RuntimeError, "Unknown Player Command Action ID"
end
end
def handle(%{packet_type: :keep_alive, id: id}, 767, state) do
ka = state |> Map.get(:keepalive)
send(ka, {:respond, id})
:ok
end
# This function should be started on a new task under the connection handler
# and is responsible for keepalive logic.
def keepalive_loop(player) do
Process.link(player) # Is it fine to do this on loop?
<<id::32>> = :rand.bytes(4)
send(player, {:send_packet, %{packet_type: :keep_alive, id: id}})
receive do
{:respond, ^id} ->
:timer.sleep(250)
keepalive_loop(player)
after
15_000 ->
send(player, {:disconnect, "Timed out! Connection overloaded?"})
end
end
def disconnect(reason) do
%{
packet_type: :disconnect,
reason: {:compound, %{
"text" => {:string, reason}
}}
}
end
end

View File

@ -1,60 +0,0 @@
# Amethyst - An experimental Minecraft server written in Elixir.
# Copyright (C) 2024 KodiCraft
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
defmodule Amethyst.ConnectionState.Status do
require Amethyst.ConnectionState.Macros
alias Amethyst.ConnectionState.Macros
require Logger
@moduledoc """
This module contains the packets and logic for the Status state.
"""
Macros.defpacket_clientbound :status_response, 0x00, 767, [json_response: :string]
Macros.defpacket_clientbound :pong_response, 0x01, 767, [payload: :long]
Macros.defpacket_serverbound :status_request, 0x00, 767, []
Macros.defpacket_serverbound :ping_request, 0x01, 767, [payload: :long]
def handle(%{packet_type: :status_request}, 767, _state) do
Logger.debug("Received status request")
send(self(), {:send_packet, %{
packet_type: :status_response,
json_response: ~s({
"version": {"name": "1.21", "protocol": 767},
"players": {"max": -1, "online": 69, "sample": [{"name": "§dAmethyst§r", "id": "4566e69f-c907-48ee-8d71-d7ba5aa00d20"}]},
"description": {"text":"Amethyst is an experimental server written in Elixir"},
"enforcesSecureChat": false,
"previewsChat": false,
"preventsChatReports": true
})
}})
:ok
end
def handle(%{packet_type: :ping_request, payload: payload}, 767, _state) do
Logger.debug("Received ping request")
send(self(), {:send_packet, %{
packet_type: :pong_response,
payload: payload
}})
:ok
end
def disconnect(_reason) do
# When disconnecting from the status state, we can't send any sort of reason.
end
end

View File

@ -20,7 +20,7 @@ defmodule Amethyst.MixProject do
source_url: "https://git.colon-three.com/kodi/amethyst",
docs: [
main: "readme",
extras: ["../../README.md", "../../LICENSE.md"]
extras: ["../README.md", "../LICENSE.md"]
]
]
end
@ -38,8 +38,7 @@ defmodule Amethyst.MixProject do
[
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:uuid, "~> 1.1"},
{:ex_doc, "~> 0.22", only: :dev, runtime: false},
{:jason, "~> 1.4"}
{:ex_doc, "~> 0.22", only: :dev, runtime: false}
]
end
end

View File

@ -1,60 +0,0 @@
defmodule GameCoordinatorTestGame do
@behaviour Amethyst.Game
@moduledoc """
This module is a sample game for the purpose of
testing the GameCoordinator
"""
@impl true
def instantiate(supervisor) do
{:ok, %{test: "test"}}
end
@impl true
def login(from, cfg, refs) do
:accept
end
@impl true
def joinable?(refs) do
true
end
@impl true
def player_position(from, {x, y, z}, refs) do
:ok
end
end
defmodule GameCoordinatorTest do
use ExUnit.Case, async: true
@moduledoc """
This module includes tests for Amethyst.GameCoordinator
"""
test "Create an instance of a game with create/1 then try to find it" do
%Amethyst.GameCoordinator.Game{refs: refs, gid: gid, mod: mod} = Amethyst.GameCoordinator.create(GameCoordinatorTestGame)
assert mod == GameCoordinatorTestGame
assert refs[:test] == "test"
found_game = Amethyst.GameCoordinator.find(GameCoordinatorTestGame)
assert found_game.mod == GameCoordinatorTestGame
assert found_game.refs[:test] == "test"
assert found_game.gid == gid
Amethyst.GameCoordinator.remove(gid)
end
test "Create an instance of a game with find_or_create/1 and then find it with find_or_create/1" do
%Amethyst.GameCoordinator.Game{refs: refs, gid: gid, mod: mod} = Amethyst.GameCoordinator.find_or_create(GameCoordinatorTestGame)
assert mod == GameCoordinatorTestGame
assert refs[:test] == "test"
found_game = Amethyst.GameCoordinator.find_or_create(GameCoordinatorTestGame)
assert found_game.mod == GameCoordinatorTestGame
assert found_game.refs[:test] == "test"
assert found_game.gid == gid
Amethyst.GameCoordinator.remove(gid)
end
end

View File

@ -1,188 +0,0 @@
%{
#
# You can have as many configs as you like in the `configs:` field.
configs: [
%{
#
# Run any config using `mix credo -C <name>`. If no config name is given
# "default" is used.
#
name: "default",
#
# These are the files included in the analysis:
files: %{
#
# You can give explicit globs or simply directories.
# In the latter case `**/*.{ex,exs}` will be used.
#
included: [
"lib/",
"src/",
"test/",
],
excluded: [~r"/_build/", ~r"/deps/"]
},
plugins: [],
requires: [],
strict: false,
parse_timeout: 5000,
color: true,
#
# You can customize the parameters of any check by adding a second element
# to the tuple.
#
# To disable a check put `false` as second element:
#
# {Credo.Check.Design.DuplicatedCode, false}
#
checks: %{
enabled: [
#
## Consistency Checks
#
{Credo.Check.Consistency.ExceptionNames, []},
{Credo.Check.Consistency.LineEndings, []},
{Credo.Check.Consistency.ParameterPatternMatching, []},
{Credo.Check.Consistency.SpaceAroundOperators, []},
{Credo.Check.Consistency.SpaceInParentheses, []},
{Credo.Check.Consistency.TabsOrSpaces, []},
#
## Design Checks
#
# You can customize the priority of any check
# Priority values are: `low, normal, high, higher`
#
{Credo.Check.Design.AliasUsage,
[priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
{Credo.Check.Design.TagFIXME, []},
# You can also customize the exit_status of each check.
# If you don't want TODO comments to cause `mix credo` to fail, just
# set this value to 0 (zero).
#
{Credo.Check.Design.TagTODO, [exit_status: 0]},
#
## Readability Checks
#
{Credo.Check.Readability.AliasOrder, []},
{Credo.Check.Readability.FunctionNames, []},
{Credo.Check.Readability.LargeNumbers, []},
{Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]},
{Credo.Check.Readability.ModuleAttributeNames, []},
{Credo.Check.Readability.ModuleDoc, []},
{Credo.Check.Readability.ModuleNames, []},
{Credo.Check.Readability.ParenthesesInCondition, []},
{Credo.Check.Readability.ParenthesesOnZeroArityDefs, []},
{Credo.Check.Readability.PipeIntoAnonymousFunctions, []},
{Credo.Check.Readability.PredicateFunctionNames, []},
{Credo.Check.Readability.PreferImplicitTry, []},
{Credo.Check.Readability.RedundantBlankLines, []},
{Credo.Check.Readability.Semicolons, []},
{Credo.Check.Readability.SpaceAfterCommas, []},
{Credo.Check.Readability.StringSigils, []},
{Credo.Check.Readability.TrailingBlankLine, []},
{Credo.Check.Readability.TrailingWhiteSpace, []},
{Credo.Check.Readability.UnnecessaryAliasExpansion, []},
{Credo.Check.Readability.VariableNames, []},
{Credo.Check.Readability.WithSingleClause, []},
#
## Refactoring Opportunities
#
{Credo.Check.Refactor.Apply, []},
{Credo.Check.Refactor.CondStatements, []},
{Credo.Check.Refactor.CyclomaticComplexity, []},
{Credo.Check.Refactor.FilterCount, []},
{Credo.Check.Refactor.FilterFilter, []},
{Credo.Check.Refactor.FunctionArity, []},
{Credo.Check.Refactor.LongQuoteBlocks, []},
{Credo.Check.Refactor.MapJoin, []},
{Credo.Check.Refactor.MatchInCondition, []},
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
{Credo.Check.Refactor.NegatedConditionsWithElse, []},
{Credo.Check.Refactor.Nesting, []},
{Credo.Check.Refactor.RedundantWithClauseResult, []},
{Credo.Check.Refactor.RejectReject, []},
{Credo.Check.Refactor.UnlessWithElse, []},
{Credo.Check.Refactor.WithClauses, []},
#
## Warnings
#
{Credo.Check.Warning.ApplicationConfigInModuleAttribute, []},
{Credo.Check.Warning.BoolOperationOnSameValues, []},
{Credo.Check.Warning.Dbg, []},
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
{Credo.Check.Warning.IExPry, []},
{Credo.Check.Warning.IoInspect, []},
{Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []},
{Credo.Check.Warning.OperationOnSameValues, []},
{Credo.Check.Warning.OperationWithConstantResult, []},
{Credo.Check.Warning.RaiseInsideRescue, []},
{Credo.Check.Warning.SpecWithStruct, []},
{Credo.Check.Warning.UnsafeExec, []},
{Credo.Check.Warning.UnusedEnumOperation, []},
{Credo.Check.Warning.UnusedFileOperation, []},
{Credo.Check.Warning.UnusedKeywordOperation, []},
{Credo.Check.Warning.UnusedListOperation, []},
{Credo.Check.Warning.UnusedPathOperation, []},
{Credo.Check.Warning.UnusedRegexOperation, []},
{Credo.Check.Warning.UnusedStringOperation, []},
{Credo.Check.Warning.UnusedTupleOperation, []},
{Credo.Check.Warning.WrongTestFileExtension, []}
],
disabled: [
#
# Checks scheduled for next check update (opt-in for now)
{Credo.Check.Refactor.UtcNowTruncate, []},
#
# Controversial and experimental checks (opt-in, just move the check to `:enabled`
# and be sure to use `mix credo --strict` to see low priority checks)
#
{Credo.Check.Consistency.MultiAliasImportRequireUse, []},
{Credo.Check.Consistency.UnusedVariableNames, []},
{Credo.Check.Design.DuplicatedCode, []},
{Credo.Check.Design.SkipTestWithoutComment, []},
{Credo.Check.Readability.AliasAs, []},
{Credo.Check.Readability.BlockPipe, []},
{Credo.Check.Readability.ImplTrue, []},
{Credo.Check.Readability.MultiAlias, []},
{Credo.Check.Readability.NestedFunctionCalls, []},
{Credo.Check.Readability.OneArityFunctionInPipe, []},
{Credo.Check.Readability.OnePipePerLine, []},
{Credo.Check.Readability.SeparateAliasRequire, []},
{Credo.Check.Readability.SingleFunctionToBlockPipe, []},
{Credo.Check.Readability.SinglePipe, []},
{Credo.Check.Readability.Specs, []},
{Credo.Check.Readability.StrictModuleLayout, []},
{Credo.Check.Readability.WithCustomTaggedTuple, []},
{Credo.Check.Refactor.ABCSize, []},
{Credo.Check.Refactor.AppendSingleItem, []},
{Credo.Check.Refactor.DoubleBooleanNegation, []},
{Credo.Check.Refactor.FilterReject, []},
{Credo.Check.Refactor.IoPuts, []},
{Credo.Check.Refactor.MapMap, []},
{Credo.Check.Refactor.ModuleDependencies, []},
{Credo.Check.Refactor.NegatedIsNil, []},
{Credo.Check.Refactor.PassAsyncInTestCases, []},
{Credo.Check.Refactor.PipeChainStart, []},
{Credo.Check.Refactor.RejectFilter, []},
{Credo.Check.Refactor.VariableRebinding, []},
{Credo.Check.Warning.LazyLogging, []},
{Credo.Check.Warning.LeakyEnvironment, []},
{Credo.Check.Warning.MapGetUnsafePass, []},
{Credo.Check.Warning.MixEnv, []},
{Credo.Check.Warning.UnsafeToAtom, []}
# {Credo.Check.Refactor.MapInto, []},
#
# Custom checks can be created using `mix credo.gen.check`.
#
]
}
}
]
}

View File

@ -1,4 +0,0 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

View File

@ -1,26 +0,0 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where third-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Ignore package tarball (built via "mix hex.build").
example_game-*.tar
# Temporary files, for example, from tests.
/tmp/

View File

@ -1,3 +0,0 @@
# Example Game
This module is a simple game which is made to aid in designing and testing Amethyst's APIs.

View File

@ -1,18 +0,0 @@
defmodule Example.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 = [
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Example.Supervisor]
Supervisor.start_link(children, opts)
end
end

View File

@ -1,58 +0,0 @@
defmodule Example.Game do
require Logger
@behaviour Amethyst.Game
@impl true
def instantiate(supervisor) do
Logger.info("The supervisor for this game is at #{inspect(supervisor)}")
{:ok, %{}}
end
@impl true
def login(from, cfg, refs) do
Logger.info("Player logged in from #{inspect(from)}: #{inspect(cfg)}")
Logger.info("The refs for this game are #{inspect(refs)}")
{:accept, {0.0, 270.0, 0.0}, {0.0, 0.0}}
end
@impl true
@spec player_position(any(), {any(), any(), any()}, any()) :: :ok
def player_position(from, {x, y, z}, _refs) do
# Logger.info("Player at #{inspect(from)} moved to #{x}, #{y}, #{z}")
send(from, {:set_position, {x, y, z}})
:ok
end
@impl true
def player_rotation(_from, {_yaw, _pitch}, _refs) do
# Logger.info("Player at #{inspect(from)} rotated to #{yaw}, #{pitch}")
:ok
end
@impl true
def accept_teleport(from, id, _state_refs) do
Logger.info("Player at #{inspect(from)} accepted teleport #{inspect(id)}")
:ok
end
@impl true
def joinable?(_refs) do
true
end
@impl true
def chunk(_from, {_cx, _cz}, _state_refs) do
# Logger.info("Player at #{inspect(from)} wants to know chunk #{cx}, #{cz}")
(0..255) |> Enum.map(fn y ->
(0..15) |> Enum.map(fn z ->
(0..15) |> Enum.map(fn x ->
if y <= x + z do
3
else
0
end
end)
end)
end)
end
end

View File

@ -1,32 +0,0 @@
defmodule Example.MixProject do
use Mix.Project
def project do
[
app: :example_game,
version: "0.1.0",
build_path: "../../_build",
config_path: "../../config/config.exs",
deps_path: "../../deps",
lockfile: "../../mix.lock",
elixir: "~> 1.17",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger],
mod: {Example.Application, []}
]
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:amethyst, in_umbrella: true}
]
end
end

View File

@ -8,7 +8,11 @@
# configurations or dependencies per app, it is best to
# move said applications out of the umbrella.
import Config
config :logger, :console,
level: :debug,
format: "$date $time [$level] $metadata$message\n",
metadata: []
# Sample configuration:
#
# config :logger, :console,
# level: :info,
# format: "$date $time [$level] $metadata$message\n",
# metadata: [:user_id]
#

View File

@ -3,5 +3,4 @@ import Config
config :amethyst,
port: 25599, # Bogus port for testing, avoids unexpected conflicts
encryption: false, # Whether or not to request encryption from clients.
auth: false, # Whether or not users should be authenticated with Mojang.
default_game: Example.Game # Which game new players should be sent to
auth: false # Whether or not users should be authenticated with Mojang.

View File

@ -57,7 +57,7 @@
}).overrideAttrs (old: {
preInstall = ''
# Run automated tests
mix test --no-deps-check --color
mix test --no-deps-check --no-start --color
'';
});
@ -83,8 +83,6 @@
elixir
elixir-ls
mix2nix
pre-commit
];
};
}

10
mix.exs
View File

@ -8,19 +8,11 @@ defmodule AmethystUmbrella.MixProject do
elixir: "~> 1.17",
start_permanent: Mix.env() == :prod,
deps: deps(),
default_release: :amethyst,
releases: [
amethyst: [
version: "0.1.0",
applications: [amethyst: :permanent]
],
example: [
version: "0.1.0",
applications: [
amethyst: :permanent,
example_game: :permanent
]
]
]
]
@ -32,6 +24,6 @@ defmodule AmethystUmbrella.MixProject do
#
# Run "mix help deps" for examples and options.
defp deps do
[{:pre_commit, "~> 0.3.4", only: :dev}]
[]
end
end

View File

@ -2,7 +2,6 @@
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"},
"earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"},
"elixir_math": {:hex, :elixir_math, "0.1.2", "5655bdf7f34e30906f31cdcf3031b43dd522ce8d2936b60ad4696b2c752bf5c9", [:mix], [], "hexpm", "34f4e4384903097a8ec566784fa8e9aa2b741247d225741f07cc48250c2aa64c"},
"ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"},
"file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
@ -10,6 +9,5 @@
"makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
"pre_commit": {:hex, :pre_commit, "0.3.4", "e2850f80be8090d50ad8019ef2426039307ff5dfbe70c736ad0d4d401facf304", [:mix], [], "hexpm", "16f684ba4f1fed1cba6b19e082b0f8d696e6f1c679285fedf442296617ba5f4e"},
"uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"},
}