Compare commits

...

50 Commits

Author SHA1 Message Date
6ad910d4b4
Remove debug logs used for troubleshooting
All checks were successful
Build & Test / nix-build (pull_request) Successful in 2m40s
Build & Test / nix-build (push) Successful in 1m24s
2024-10-04 11:42:21 +02:00
b40fee3c3c
Fix bugs in sending paletted chunks
All checks were successful
Build & Test / nix-build (pull_request) Successful in 2m37s
Build & Test / nix-build (push) Successful in 2m39s
2024-10-04 11:36:08 +02:00
288ead3e9b
Implement rudimentary keepalive
All checks were successful
Build & Test / nix-build (pull_request) Successful in 2m35s
Build & Test / nix-build (push) Successful in 2m38s
2024-10-04 10:33:41 +02:00
fb0099352b
Some experimentation
All checks were successful
Build & Test / nix-build (pull_request) Successful in 2m37s
Build & Test / nix-build (push) Successful in 2m39s
2024-10-04 09:45:23 +02:00
53243c14fa
Load into the world!... somewhat
All checks were successful
Build & Test / nix-build (push) Successful in 2m35s
Build & Test / nix-build (pull_request) Successful in 2m39s
2024-10-03 22:14:00 +02:00
f25e6fbf93
Merge branch 'first-attempt' of git.colon-three.com:kodi/amethyst into first-attempt
All checks were successful
Build & Test / nix-build (push) Successful in 2m38s
Build & Test / nix-build (pull_request) Successful in 2m41s
2024-10-03 20:36:37 +02:00
f083a99702
merge 2024-10-03 20:36:26 +02:00
7821b20758
Minor tweaks 2024-10-03 20:35:50 +02:00
847644d8cf
Performance tweaks
All checks were successful
Build & Test / nix-build (pull_request) Successful in 2m36s
Build & Test / nix-build (push) Successful in 2m40s
2024-10-03 17:55:43 +02:00
18a80874df
Fix weird bug (dont use vscode)
All checks were successful
Build & Test / nix-build (push) Successful in 2m36s
Build & Test / nix-build (pull_request) Successful in 2m39s
2024-10-03 14:55:40 +02:00
d0a5b55ae8
try fixing extremely weird bug
All checks were successful
Build & Test / nix-build (pull_request) Successful in 2m37s
Build & Test / nix-build (push) Successful in 2m38s
2024-10-02 17:35:28 +02:00
af76cace76
Finish trying to implement sending chunks 2024-10-02 15:47:21 +02:00
c3c3f83286
more work on sending map
All checks were successful
Build & Test / nix-build (push) Successful in 2m39s
Build & Test / nix-build (pull_request) Successful in 2m40s
2024-10-01 20:01:14 +02:00
40792b8d94
extremely minor
All checks were successful
Build & Test / nix-build (pull_request) Successful in 2m37s
Build & Test / nix-build (push) Successful in 2m40s
2024-10-01 19:43:29 +02:00
5fae9aa1ff
Some progress towards sending the map
All checks were successful
Build & Test / nix-build (pull_request) Successful in 2m39s
Build & Test / nix-build (push) Successful in 2m40s
2024-10-01 19:26:24 +02:00
453daa817b
Implement part of chunk sending
All checks were successful
Build & Test / nix-build (pull_request) Successful in 2m31s
Build & Test / nix-build (push) Successful in 2m37s
2024-10-01 10:51:08 +02:00
c880ea95f3
Implement player info update packet
All checks were successful
Build & Test / nix-build (push) Successful in 3m10s
Build & Test / nix-build (pull_request) Successful in 3m10s
2024-10-01 08:21:30 +02:00
f0c2ef80ec
work on implementing some more packets
All checks were successful
Build & Test / nix-build (pull_request) Successful in 2m38s
Build & Test / nix-build (push) Successful in 2m39s
2024-09-26 14:47:05 +02:00
034f21ade7
begin implementing the second worst packet of this protocol
All checks were successful
Build & Test / nix-build (pull_request) Successful in 3m24s
Build & Test / nix-build (push) Successful in 3m25s
2024-09-25 11:09:26 +02:00
930a508ad9
"Loading terrain..."!
All checks were successful
Build & Test / nix-build (pull_request) Successful in 2m38s
Build & Test / nix-build (push) Successful in 2m42s
2024-09-19 15:40:16 +02:00
f037f0de02
No longer rely on loose error checking and other minor fixes
All checks were successful
Build & Test / nix-build (push) Successful in 2m42s
Build & Test / nix-build (pull_request) Successful in 2m45s
2024-09-19 15:11:59 +02:00
764c4bc387
Minor doc update
All checks were successful
Build & Test / nix-build (pull_request) Successful in 2m36s
Build & Test / nix-build (push) Successful in 2m43s
2024-09-17 20:01:44 +02:00
f79e0728e6
Implement callback for accept_teleport
All checks were successful
Build & Test / nix-build (pull_request) Successful in 2m43s
Build & Test / nix-build (push) Successful in 2m46s
2024-09-17 19:50:21 +02:00
53fe25043d
Implement handling confirm_teleportation
All checks were successful
Build & Test / nix-build (push) Successful in 2m50s
Build & Test / nix-build (pull_request) Successful in 2m52s
2024-09-17 19:23:39 +02:00
0fdc00148e
Clean some warnings
All checks were successful
Build & Test / nix-build (push) Successful in 3m36s
Build & Test / nix-build (pull_request) Successful in 3m36s
2024-09-09 18:45:52 +02:00
5063e8af12
Implement packet type checking and get back up to speed
All checks were successful
Build & Test / nix-build (push) Successful in 1m31s
Build & Test / nix-build (pull_request) Successful in 1m27s
2024-09-07 12:34:59 +02:00
4beb083dd6
Progress on getting back up to speed
All checks were successful
Build & Test / nix-build (push) Successful in 1m32s
2024-09-06 19:53:48 +02:00
9ec63487c2
display packet ID in hexadecimal
All checks were successful
Build & Test / nix-build (push) Successful in 1m34s
2024-09-06 12:45:05 +02:00
6495a246e0
Fix disconnect messages and progress on Configuration stage
All checks were successful
Build & Test / nix-build (push) Successful in 1m30s
2024-09-06 12:07:14 +02:00
1ced941440
Continue rewrite
All checks were successful
Build & Test / nix-build (push) Successful in 1m34s
2024-09-06 00:42:09 +02:00
5413708b29
Begin complete rewrite of communication system
All checks were successful
Build & Test / nix-build (push) Successful in 1m31s
2024-09-05 19:06:35 +02:00
9842195b8e
Some patching up
All checks were successful
Build & Test / nix-build (push) Successful in 1m28s
2024-09-04 13:13:34 +02:00
acc814a056
Respect result of Amethyst.API.Game.login/2
All checks were successful
Build & Test / nix-build (push) Successful in 1m33s
2024-09-04 13:10:39 +02:00
903d2fd3be
Update nix tests
All checks were successful
Build & Test / nix-build (push) Successful in 1m39s
2024-09-04 11:57:36 +02:00
773d1c567d
Add pre-commit checks
Some checks failed
Build & Test / nix-build (push) Failing after 23s
2024-09-04 11:55:37 +02:00
3be9b8d908
Updated unit tests
Some checks failed
Build & Test / nix-build (push) Failing after 18s
2024-09-04 11:44:04 +02:00
4c5f0370a0
Add function for removing game from GameCoordinator
Some checks failed
Build & Test / nix-build (push) Failing after 18s
2024-09-04 11:27:45 +02:00
d63bd0a9b2
Actually listen to the joinable? function
Some checks failed
Build & Test / nix-build (push) Failing after 27s
2024-09-04 11:24:04 +02:00
8ae0c08e8d
Complete refactoring
Some checks failed
Build & Test / nix-build (push) Failing after 28s
2024-09-03 19:29:19 +02:00
4a5ccc719d
Some significant refactoring progress
Some checks failed
Build & Test / nix-build (push) Failing after 59s
2024-08-28 14:52:13 +02:00
fb2a21a546
Run all game processes in a game supervisor 2024-08-25 21:03:09 +02:00
dc9a2f2b5f
Some changes to how games are created
Some checks failed
Build & Test / nix-build (push) Failing after 27s
2024-08-25 13:17:25 +02:00
ec7119251c
Begin implementing :set_player_position packet in Play
Some checks failed
Build & Test / nix-build (push) Failing after 17s
2024-08-19 00:53:34 +02:00
c7d3b139fe
Add tests for GameCoordinator
Some checks failed
Build & Test / nix-build (push) Failing after 17s
2024-08-19 00:15:29 +02:00
450ca4d53a
Implement tests for GameRegistry
Some checks failed
Build & Test / nix-build (push) Failing after 41s
2024-08-19 00:00:03 +02:00
78442a94af
Begin implementing logging player into a game
All checks were successful
Build & Test / nix-build (push) Successful in 1m9s
2024-08-18 13:44:09 +02:00
11db275db2
Add GameCoordinator logic for finding or creating games
All checks were successful
Build & Test / nix-build (push) Successful in 1m14s
2024-08-18 13:02:48 +02:00
99af42f42c
run actions on all pushes
All checks were successful
Build & Test / nix-build (push) Successful in 1m15s
2024-08-17 19:52:11 +02:00
935fa9a49e
(m) Add release which includes example game 2024-08-17 19:51:28 +02:00
4a56f9b954
Begin implementing GameCoordinator 2024-08-17 19:43:08 +02:00
36 changed files with 2002 additions and 850 deletions

View File

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

View File

@ -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

View 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

View File

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

View File

@ -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

View File

@ -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)

View File

@ -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
View 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

View File

@ -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

View File

@ -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

View File

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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@ -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

View 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

View 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`.
#
]
}
}
]
}

View 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
View 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/

View 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.

View 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

View 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
View 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

View File

@ -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]
#

View File

@ -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

View File

@ -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
View File

@ -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

View File

@ -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"},
} }