Compare commits
50 Commits
4e629fb8e4
...
6ad910d4b4
Author | SHA1 | Date | |
---|---|---|---|
6ad910d4b4 | |||
b40fee3c3c | |||
288ead3e9b | |||
fb0099352b | |||
53243c14fa | |||
f25e6fbf93 | |||
f083a99702 | |||
7821b20758 | |||
847644d8cf | |||
18a80874df | |||
d0a5b55ae8 | |||
af76cace76 | |||
c3c3f83286 | |||
40792b8d94 | |||
5fae9aa1ff | |||
453daa817b | |||
c880ea95f3 | |||
f0c2ef80ec | |||
034f21ade7 | |||
930a508ad9 | |||
f037f0de02 | |||
764c4bc387 | |||
f79e0728e6 | |||
53fe25043d | |||
0fdc00148e | |||
5063e8af12 | |||
4beb083dd6 | |||
9ec63487c2 | |||
6495a246e0 | |||
1ced941440 | |||
5413708b29 | |||
9842195b8e | |||
acc814a056 | |||
903d2fd3be | |||
773d1c567d | |||
3be9b8d908 | |||
4c5f0370a0 | |||
d63bd0a9b2 | |||
8ae0c08e8d | |||
4a5ccc719d | |||
fb2a21a546 | |||
dc9a2f2b5f | |||
ec7119251c | |||
c7d3b139fe | |||
450ca4d53a | |||
78442a94af | |||
11db275db2 | |||
99af42f42c | |||
935fa9a49e | |||
4a56f9b954 |
@ -2,8 +2,6 @@ name: Build & Test
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
@ -24,8 +24,13 @@ defmodule Amethyst.Application do
|
|||||||
@impl true
|
@impl true
|
||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
children = [
|
children = [
|
||||||
{Task.Supervisor, name: Amethyst.ConnectionSupervisor},
|
{DynamicSupervisor, name: Amethyst.ConnectionSupervisor},
|
||||||
{Amethyst.Keys, 1024},
|
{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
|
children = case Application.fetch_env!(:amethyst, :port) do
|
||||||
|
261
apps/amethyst/lib/apps/connection_handler.ex
Normal file
261
apps/amethyst/lib/apps/connection_handler.ex
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
# 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
|
87
apps/amethyst/lib/apps/connection_receiver.ex
Normal file
87
apps/amethyst/lib/apps/connection_receiver.ex
Normal 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.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
|
131
apps/amethyst/lib/apps/game_coordinator.ex
Normal file
131
apps/amethyst/lib/apps/game_coordinator.ex
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
# 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
|
@ -21,7 +21,7 @@ defmodule Amethyst.TCPListener do
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def accept(port) do
|
def accept(port) do
|
||||||
{:ok, socket} = :gen_tcp.listen(port, [:binary, packet: 0, active: false, reuseaddr: true])
|
{:ok, socket} = :gen_tcp.listen(port, [:binary, packet: 0, active: false, reuseaddr: true, nodelay: true])
|
||||||
Logger.info("Listening on port #{port}")
|
Logger.info("Listening on port #{port}")
|
||||||
loop_acceptor(socket)
|
loop_acceptor(socket)
|
||||||
end
|
end
|
||||||
@ -29,7 +29,7 @@ defmodule Amethyst.TCPListener do
|
|||||||
@spec loop_acceptor(socket :: :gen_tcp.socket()) :: no_return()
|
@spec loop_acceptor(socket :: :gen_tcp.socket()) :: no_return()
|
||||||
defp loop_acceptor(socket) do
|
defp loop_acceptor(socket) do
|
||||||
{:ok, client} = :gen_tcp.accept(socket)
|
{:ok, client} = :gen_tcp.accept(socket)
|
||||||
{:ok, pid} = Task.Supervisor.start_child(Amethyst.ConnectionSupervisor, fn -> Amethyst.Server.Handshake.serve(client) end)
|
{:ok, pid} = DynamicSupervisor.start_child(Amethyst.ConnectionSupervisor, {Amethyst.ConnectionReceiver, client})
|
||||||
:ok = :gen_tcp.controlling_process(client, pid)
|
:ok = :gen_tcp.controlling_process(client, pid)
|
||||||
Logger.info("Received connection from #{inspect(client)}, assigned to PID #{inspect(pid)}")
|
Logger.info("Received connection from #{inspect(client)}, assigned to PID #{inspect(pid)}")
|
||||||
loop_acceptor(socket)
|
loop_acceptor(socket)
|
||||||
|
@ -35,6 +35,10 @@ defmodule Amethyst.Minecraft.Write do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def raw(value) do
|
||||||
|
value
|
||||||
|
end
|
||||||
|
|
||||||
def byte(value) when value in -128..127 do
|
def byte(value) when value in -128..127 do
|
||||||
<<value::8-signed-big>>
|
<<value::8-signed-big>>
|
||||||
end
|
end
|
||||||
@ -98,6 +102,14 @@ defmodule Amethyst.Minecraft.Write do
|
|||||||
<<varint(byte_size(value))::binary, value::binary>>
|
<<varint(byte_size(value))::binary, value::binary>>
|
||||||
end
|
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
|
def position({x, y, z}) do
|
||||||
<<x::signed-big-26, z::signed-big-26, y::signed-big-12>>
|
<<x::signed-big-26, z::signed-big-26, y::signed-big-12>>
|
||||||
end
|
end
|
||||||
@ -123,6 +135,10 @@ defmodule Amethyst.Minecraft.Write do
|
|||||||
v -> bool(true) <> callback.(v)
|
v -> bool(true) <> callback.(v)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def nbt(value) do
|
||||||
|
Amethyst.NBT.Write.write_net(value)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defmodule Amethyst.Minecraft.Read do
|
defmodule Amethyst.Minecraft.Read do
|
||||||
@ -243,4 +259,11 @@ defmodule Amethyst.Minecraft.Read do
|
|||||||
<<value::binary-size(length), rest::binary>> = rest
|
<<value::binary-size(length), rest::binary>> = rest
|
||||||
{[value | acc], rest, :reversed}
|
{[value | acc], rest, :reversed}
|
||||||
end
|
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
|
end
|
||||||
|
107
apps/amethyst/lib/game.ex
Normal file
107
apps/amethyst/lib/game.ex
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
# 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
|
@ -27,6 +27,20 @@ defmodule Amethyst.NBT.Write do
|
|||||||
<<type_id(type)::size(8), payload(type, value)::binary>>
|
<<type_id(type)::size(8), payload(type, value)::binary>>
|
||||||
end
|
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(:end), do: 0
|
||||||
defp type_id(:byte), do: 1
|
defp type_id(:byte), do: 1
|
||||||
defp type_id(:short), do: 2
|
defp type_id(:short), do: 2
|
||||||
|
@ -1,355 +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.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
|
|
@ -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.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
|
|
@ -1,72 +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.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
|
|
@ -1,136 +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.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
|
|
@ -1,97 +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.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
|
|
@ -1,85 +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.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
|
|
326
apps/amethyst/lib/states/configuration.ex
Normal file
326
apps/amethyst/lib/states/configuration.ex
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
# 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
|
56
apps/amethyst/lib/states/handhsake.ex
Normal file
56
apps/amethyst/lib/states/handhsake.ex
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# 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
|
92
apps/amethyst/lib/states/login.ex
Normal file
92
apps/amethyst/lib/states/login.ex
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
# 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
|
160
apps/amethyst/lib/states/macros.ex
Normal file
160
apps/amethyst/lib/states/macros.ex
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
# 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
|
265
apps/amethyst/lib/states/play.ex
Normal file
265
apps/amethyst/lib/states/play.ex
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
# 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
|
60
apps/amethyst/lib/states/status.ex
Normal file
60
apps/amethyst/lib/states/status.ex
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# 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
|
@ -20,7 +20,7 @@ defmodule Amethyst.MixProject do
|
|||||||
source_url: "https://git.colon-three.com/kodi/amethyst",
|
source_url: "https://git.colon-three.com/kodi/amethyst",
|
||||||
docs: [
|
docs: [
|
||||||
main: "readme",
|
main: "readme",
|
||||||
extras: ["../README.md", "../LICENSE.md"]
|
extras: ["../../README.md", "../../LICENSE.md"]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
@ -38,7 +38,8 @@ defmodule Amethyst.MixProject do
|
|||||||
[
|
[
|
||||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||||
{:uuid, "~> 1.1"},
|
{:uuid, "~> 1.1"},
|
||||||
{:ex_doc, "~> 0.22", only: :dev, runtime: false}
|
{:ex_doc, "~> 0.22", only: :dev, runtime: false},
|
||||||
|
{:jason, "~> 1.4"}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
60
apps/amethyst/test/game_coordinator_test.exs
Normal file
60
apps/amethyst/test/game_coordinator_test.exs
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
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
|
188
apps/example_game/.credo.exs
Normal file
188
apps/example_game/.credo.exs
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
%{
|
||||||
|
#
|
||||||
|
# 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`.
|
||||||
|
#
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
4
apps/example_game/.formatter.exs
Normal file
4
apps/example_game/.formatter.exs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# Used by "mix format"
|
||||||
|
[
|
||||||
|
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
||||||
|
]
|
26
apps/example_game/.gitignore
vendored
Normal file
26
apps/example_game/.gitignore
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# 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/
|
3
apps/example_game/README.md
Normal file
3
apps/example_game/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Example Game
|
||||||
|
|
||||||
|
This module is a simple game which is made to aid in designing and testing Amethyst's APIs.
|
18
apps/example_game/lib/example/application.ex
Normal file
18
apps/example_game/lib/example/application.ex
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
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
|
58
apps/example_game/lib/example/game.ex
Normal file
58
apps/example_game/lib/example/game.ex
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
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
|
32
apps/example_game/mix.exs
Normal file
32
apps/example_game/mix.exs
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
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
|
@ -8,11 +8,7 @@
|
|||||||
# configurations or dependencies per app, it is best to
|
# configurations or dependencies per app, it is best to
|
||||||
# move said applications out of the umbrella.
|
# move said applications out of the umbrella.
|
||||||
import Config
|
import Config
|
||||||
|
config :logger, :console,
|
||||||
# Sample configuration:
|
level: :debug,
|
||||||
#
|
format: "$date $time [$level] $metadata$message\n",
|
||||||
# config :logger, :console,
|
metadata: []
|
||||||
# level: :info,
|
|
||||||
# format: "$date $time [$level] $metadata$message\n",
|
|
||||||
# metadata: [:user_id]
|
|
||||||
#
|
|
||||||
|
@ -3,4 +3,5 @@ import Config
|
|||||||
config :amethyst,
|
config :amethyst,
|
||||||
port: 25599, # Bogus port for testing, avoids unexpected conflicts
|
port: 25599, # Bogus port for testing, avoids unexpected conflicts
|
||||||
encryption: false, # Whether or not to request encryption from clients.
|
encryption: false, # Whether or not to request encryption from clients.
|
||||||
auth: false # Whether or not users should be authenticated with Mojang.
|
auth: false, # Whether or not users should be authenticated with Mojang.
|
||||||
|
default_game: Example.Game # Which game new players should be sent to
|
||||||
|
@ -57,7 +57,7 @@
|
|||||||
}).overrideAttrs (old: {
|
}).overrideAttrs (old: {
|
||||||
preInstall = ''
|
preInstall = ''
|
||||||
# Run automated tests
|
# Run automated tests
|
||||||
mix test --no-deps-check --no-start --color
|
mix test --no-deps-check --color
|
||||||
'';
|
'';
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -83,6 +83,8 @@
|
|||||||
elixir
|
elixir
|
||||||
elixir-ls
|
elixir-ls
|
||||||
mix2nix
|
mix2nix
|
||||||
|
|
||||||
|
pre-commit
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
10
mix.exs
10
mix.exs
@ -8,11 +8,19 @@ defmodule AmethystUmbrella.MixProject do
|
|||||||
elixir: "~> 1.17",
|
elixir: "~> 1.17",
|
||||||
start_permanent: Mix.env() == :prod,
|
start_permanent: Mix.env() == :prod,
|
||||||
deps: deps(),
|
deps: deps(),
|
||||||
|
default_release: :amethyst,
|
||||||
|
|
||||||
releases: [
|
releases: [
|
||||||
amethyst: [
|
amethyst: [
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
applications: [amethyst: :permanent]
|
applications: [amethyst: :permanent]
|
||||||
|
],
|
||||||
|
example: [
|
||||||
|
version: "0.1.0",
|
||||||
|
applications: [
|
||||||
|
amethyst: :permanent,
|
||||||
|
example_game: :permanent
|
||||||
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
@ -24,6 +32,6 @@ defmodule AmethystUmbrella.MixProject do
|
|||||||
#
|
#
|
||||||
# Run "mix help deps" for examples and options.
|
# Run "mix help deps" for examples and options.
|
||||||
defp deps do
|
defp deps do
|
||||||
[]
|
[{:pre_commit, "~> 0.3.4", only: :dev}]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
2
mix.lock
2
mix.lock
@ -2,6 +2,7 @@
|
|||||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
"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"},
|
"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"},
|
"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"},
|
"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"},
|
"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"},
|
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||||
@ -9,5 +10,6 @@
|
|||||||
"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_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"},
|
"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"},
|
"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"},
|
"uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"},
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user