Compare commits
50 Commits
4e629fb8e4
...
6ad910d4b4
Author | SHA1 | Date | |
---|---|---|---|
6ad910d4b4 | |||
b40fee3c3c | |||
288ead3e9b | |||
fb0099352b | |||
53243c14fa | |||
f25e6fbf93 | |||
f083a99702 | |||
7821b20758 | |||
847644d8cf | |||
18a80874df | |||
d0a5b55ae8 | |||
af76cace76 | |||
c3c3f83286 | |||
40792b8d94 | |||
5fae9aa1ff | |||
453daa817b | |||
c880ea95f3 | |||
f0c2ef80ec | |||
034f21ade7 | |||
930a508ad9 | |||
f037f0de02 | |||
764c4bc387 | |||
f79e0728e6 | |||
53fe25043d | |||
0fdc00148e | |||
5063e8af12 | |||
4beb083dd6 | |||
9ec63487c2 | |||
6495a246e0 | |||
1ced941440 | |||
5413708b29 | |||
9842195b8e | |||
acc814a056 | |||
903d2fd3be | |||
773d1c567d | |||
3be9b8d908 | |||
4c5f0370a0 | |||
d63bd0a9b2 | |||
8ae0c08e8d | |||
4a5ccc719d | |||
fb2a21a546 | |||
dc9a2f2b5f | |||
ec7119251c | |||
c7d3b139fe | |||
450ca4d53a | |||
78442a94af | |||
11db275db2 | |||
99af42f42c | |||
935fa9a49e | |||
4a56f9b954 |
@ -2,8 +2,6 @@ name: Build & Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
|
@ -24,8 +24,13 @@ defmodule Amethyst.Application do
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
{Task.Supervisor, name: Amethyst.ConnectionSupervisor},
|
||||
{DynamicSupervisor, name: Amethyst.ConnectionSupervisor},
|
||||
{Amethyst.Keys, 1024},
|
||||
{Amethyst.GameCoordinator, %Amethyst.GameCoordinator.State{games: %{}, gid: 0}},
|
||||
{PartitionSupervisor,
|
||||
child_spec: DynamicSupervisor.child_spec([]),
|
||||
name: Amethyst.GameMetaSupervisor
|
||||
}
|
||||
]
|
||||
|
||||
children = case Application.fetch_env!(:amethyst, :port) do
|
||||
|
261
apps/amethyst/lib/apps/connection_handler.ex
Normal file
261
apps/amethyst/lib/apps/connection_handler.ex
Normal file
@ -0,0 +1,261 @@
|
||||
# Amethyst - An experimental Minecraft server written in Elixir.
|
||||
# Copyright (C) 2024 KodiCraft
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
defmodule Amethyst.ConnectionHandler do
|
||||
@moduledoc """
|
||||
This module is responsible for handling incoming packets and sending outgoing packets. It keeps track of what state the connection is in and which game should
|
||||
receive the packets.
|
||||
"""
|
||||
require Logger
|
||||
|
||||
@spec child_spec(:gen_tcp.socket()) :: Supervisor.child_spec()
|
||||
def child_spec(socket) do
|
||||
%{
|
||||
id: __MODULE__,
|
||||
start: {__MODULE__, :start, [socket, Amethyst.ConnectionState.Handshake, 0]}
|
||||
}
|
||||
end
|
||||
|
||||
@spec start(:gen_tcp.socket(), atom(), integer()) :: no_return()
|
||||
def start(socket, connstate, version) do
|
||||
{:ok, spawn(fn ->
|
||||
Process.set_label("ConnectionHandler for #{inspect(socket)}")
|
||||
loop(socket, connstate, version, %{})
|
||||
end)}
|
||||
end
|
||||
|
||||
@spec start_link(:gen_tcp.socket(), atom(), integer()) :: no_return()
|
||||
def start_link(socket, connstate, version) do
|
||||
{:ok, spawn_link(fn ->
|
||||
Process.set_label("ConnectionHandler for #{inspect(socket)}")
|
||||
loop(socket, connstate, version, %{})
|
||||
end)}
|
||||
end
|
||||
|
||||
@spec loop(:gen_tcp.socket(), atom(), integer(), map()) :: no_return()
|
||||
defp loop(socket, connstate, version, state) do
|
||||
receive do
|
||||
:closed ->
|
||||
Logger.info("Connection #{inspect(socket)} closed.")
|
||||
Process.exit(self(), :normal)
|
||||
{:disconnect, reason} ->
|
||||
disconnect(socket, reason, connstate, version)
|
||||
Process.exit(self(), :normal)
|
||||
{:set_state, newstate} ->
|
||||
Logger.debug("Switching to state #{newstate} from #{connstate}")
|
||||
loop(socket, newstate, version, state)
|
||||
{:set_version, newversion} ->
|
||||
Logger.debug("Switching to version #{newversion} from #{version}")
|
||||
loop(socket, connstate, newversion, state)
|
||||
{:set_position, position} ->
|
||||
prev_position = Map.get(state, :position)
|
||||
state = Map.put(state, :position, position)
|
||||
# If there was no prev position, we consider that we
|
||||
# definitely moved
|
||||
prev_cp = if prev_position == nil do nil else chunk_pos(elem(prev_position, 0), elem(prev_position, 2)) end
|
||||
cp = chunk_pos(elem(position, 0), elem(position, 2))
|
||||
if prev_cp != cp do
|
||||
Logger.debug("Client entered new chunk #{inspect(cp)}")
|
||||
# We changed chunk borders, update center chunk and begin sending new chunks
|
||||
send(self(), {:send_packet, %{
|
||||
packet_type: :set_center_chunk,
|
||||
chunk_x: elem(cp, 0),
|
||||
chunk_z: elem(cp, 1)
|
||||
}})
|
||||
# Figure out which new chunks are visible
|
||||
prev_chunks =
|
||||
if prev_cp == nil do
|
||||
MapSet.new([])
|
||||
else
|
||||
MapSet.new(visible_chunks_from(elem(prev_cp, 0), elem(prev_cp, 1), Map.get(state, :view_distance, 16)))
|
||||
end
|
||||
chunks = MapSet.new(visible_chunks_from(elem(cp, 0), elem(cp, 1), Map.get(state, :view_distance, 16)))
|
||||
new_chunks = MapSet.difference(chunks, prev_chunks)
|
||||
Logger.debug("Sending #{MapSet.size(new_chunks)} chunks...")
|
||||
# We can process all chunks in parallel
|
||||
me = self()
|
||||
ts = state |> Map.get(:game) |> Map.get(:refs) |> Map.get(:task_supervisor)
|
||||
Task.Supervisor.async(ts, fn ->
|
||||
Task.Supervisor.async_stream(ts,
|
||||
new_chunks,
|
||||
fn chunk -> process_chunk(me, chunk, state) end,
|
||||
[ordered: false]
|
||||
) |> Stream.run() end)
|
||||
end
|
||||
loop(socket, connstate, version, state)
|
||||
{:send_packet, packet} ->
|
||||
# Logger.debug("Sending packet #{inspect(packet)}")
|
||||
send_packet(socket, connstate, packet, version)
|
||||
loop(socket, connstate, version, state)
|
||||
after 0 ->
|
||||
receive do
|
||||
{:packet, id, data} ->
|
||||
state = handle_packet(id, data, connstate, version, state)
|
||||
loop(socket, connstate, version, state)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp chunk_pos(x, z) do
|
||||
{floor(round(x) / 16.0), floor(round(z) / 16.0)}
|
||||
end
|
||||
|
||||
# x, z here is chunk position
|
||||
defp visible_chunks_from(x, z, view_distance) do
|
||||
(x - view_distance - 3 .. x + view_distance + 3) |> Enum.flat_map(fn ix ->
|
||||
(z - view_distance - 3 .. z + view_distance + 3) |> Enum.map(fn iz ->
|
||||
{ix, iz}
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
defp process_chunk(to, chunk, state) do
|
||||
import Amethyst.NBT.Write
|
||||
alias Amethyst.Minecraft.Write
|
||||
|
||||
chunk_array = Amethyst.Game.chunk(Map.get(state, :game), chunk)
|
||||
{cx, cz} = chunk
|
||||
|
||||
# TODO: Actually do heightmaps
|
||||
# TODO: Doing all this processing could be at home somewhere else, as here it's
|
||||
# not version-agnostic here
|
||||
heightmaps = compound(%{})
|
||||
|
||||
data = Enum.chunk_every(chunk_array, 16, 16, 0) # 0 -> air
|
||||
|> Enum.reduce("", fn chunk_section, acc ->
|
||||
blocks = chunk_section |> List.flatten()
|
||||
block_count = blocks |> Enum.filter(&(&1 != 0)) |> length
|
||||
|
||||
# Put together the palette
|
||||
unique_blocks = MapSet.new(blocks)
|
||||
min_bpe = MapSet.size(unique_blocks) |> :math.log2() |> ceil()
|
||||
paletted_container_data = case min_bpe do
|
||||
0 ->
|
||||
# SINGLE VALUED
|
||||
Write.ubyte(0) <>
|
||||
Write.varint(MapSet.to_list(unique_blocks) |> List.first()) <>
|
||||
Write.varint(0) # No data, empty pallette
|
||||
min_bpe when min_bpe in 1..8 ->
|
||||
# INDIRECT
|
||||
# Minimum bpe accepted by minecraft is 4
|
||||
bpe = max(min_bpe, 4)
|
||||
palette = MapSet.to_list(unique_blocks) |>
|
||||
Enum.with_index() |>
|
||||
Map.new(fn {i, v} -> {i, v} end)
|
||||
paletted_blocks = blocks |>
|
||||
Enum.map(&(Map.get(palette, &1)))
|
||||
paletted_data = long_aligned_bit_string_reduce(paletted_blocks, bpe)
|
||||
|
||||
Write.ubyte(bpe) <>
|
||||
Write.varint(map_size(palette)) <>
|
||||
Enum.reduce(palette, "", fn {_k, v}, acc ->
|
||||
acc <> Write.varint(v)
|
||||
end) <>
|
||||
Write.varint(floor(bit_size(paletted_data) / 64)) <>
|
||||
paletted_data
|
||||
_ ->
|
||||
# DIRECT
|
||||
data = long_aligned_bit_string_reduce(blocks, 15)
|
||||
Write.ubyte(15) <>
|
||||
Write.varint(floor(bit_size(data) / 64)) <>
|
||||
data
|
||||
end
|
||||
|
||||
acc <> Write.short(block_count) <> paletted_container_data <>
|
||||
<<0::8, 0::8, 0::8>> # TODO: This should be biome data
|
||||
end)
|
||||
|
||||
send(to, {:send_packet, %{
|
||||
packet_type: :chunk_data_and_update_light,
|
||||
chunk_x: cx, chunk_z: cz,
|
||||
heightmaps: heightmaps,
|
||||
data: data,
|
||||
block_entities: [],
|
||||
# TODO: Light
|
||||
sky_light_mask: Write.varint(0),
|
||||
block_light_mask: Write.varint(0),
|
||||
empty_sky_light_mask: Write.varint(0),
|
||||
empty_block_light_mask: Write.varint(0),
|
||||
sky_light_arrays: [],
|
||||
block_light_arrays: []
|
||||
}})
|
||||
:ok
|
||||
end
|
||||
|
||||
defp long_aligned_bit_string_reduce(values, bpe) do
|
||||
values |> Enum.reduce(<<>>, fn value, acc ->
|
||||
next = <<acc::bitstring, value::big-size(bpe)>>
|
||||
# man i hope they dont suddenly change the size of a long
|
||||
if rem(bit_size(next), 64) + bpe > 64 do
|
||||
# gotta pad it
|
||||
<<next::bitstring, 0::big-size(64 - rem(bit_size(next), 64))>>
|
||||
else
|
||||
next
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp handle_packet(id, data, connstate, version, state) do
|
||||
try do
|
||||
packet = connstate.deserialize(id, version, data)
|
||||
case connstate.handle(packet, version, state) do
|
||||
:ok -> state
|
||||
{:error, reason} ->
|
||||
Logger.error("Error handling packet with ID #{inspect(id, base: :hex)} in state #{connstate}: #{reason}")
|
||||
send(self(), {:disconnect, "§cError handling packet #{inspect(id, base: :hex)}:\n#{reason}"})
|
||||
state
|
||||
newstate ->
|
||||
if is_map(newstate) do
|
||||
newstate
|
||||
else
|
||||
Logger.warning("State change to #{inspect(newstate)} is not a map! Did you forget to return :ok?")
|
||||
state
|
||||
end
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
if Mix.env() == :dev do
|
||||
Logger.error("Error handling packet with ID #{inspect(id, base: :hex)} in state #{connstate}: #{Exception.format(:error, e, __STACKTRACE__)}")
|
||||
else
|
||||
send(self(), {:disconnect, "§cError handling packet #{inspect(id, base: :hex)}:\n#{Exception.format(:error, e, __STACKTRACE__)}"})
|
||||
Logger.error("Error handling packet with ID #{inspect(id, base: :hex)} in state #{connstate}: #{Exception.format(:error, e, __STACKTRACE__)}")
|
||||
end
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
defp send_packet(socket, connstate, packet, version) do
|
||||
try do
|
||||
data = connstate.serialize(packet, version)
|
||||
length = byte_size(data) |> Amethyst.Minecraft.Write.varint()
|
||||
:gen_tcp.send(socket, length <> data)
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Error sending packet #{inspect(packet)} in state #{connstate}: #{Exception.format(:error, e, __STACKTRACE__)}")
|
||||
send(self(), {:disconnect, "§cError sending packet #{inspect(packet)}:\n#{Exception.format(:error, e, __STACKTRACE__)}"})
|
||||
end
|
||||
end
|
||||
|
||||
defp disconnect(socket, reason, connstate, version) do
|
||||
Logger.info("Disconnecting connection #{inspect(socket)}")
|
||||
Logger.debug("Disconnecting connection #{inspect(socket)}: #{reason}")
|
||||
case connstate.disconnect(reason) do
|
||||
nil -> nil
|
||||
packet -> send_packet(socket, connstate, packet, version)
|
||||
end
|
||||
:gen_tcp.close(socket)
|
||||
end
|
||||
end
|
87
apps/amethyst/lib/apps/connection_receiver.ex
Normal file
87
apps/amethyst/lib/apps/connection_receiver.ex
Normal file
@ -0,0 +1,87 @@
|
||||
# Amethyst - An experimental Minecraft server written in Elixir.
|
||||
# Copyright (C) 2024 KodiCraft
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
defmodule Amethyst.ConnectionReceiver do
|
||||
@moduledoc """
|
||||
This module waits for data incoming through a TCP connection, reads entire packets at a time and sends them to the handler.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
alias Amethyst.Minecraft.Read
|
||||
|
||||
@spec child_spec(:gen_tcp.socket()) :: Supervisor.child_spec()
|
||||
def child_spec(socket) do
|
||||
%{
|
||||
id: __MODULE__,
|
||||
start: {__MODULE__, :start, [socket]}
|
||||
}
|
||||
end
|
||||
|
||||
@spec start(:gen_tcp.socket()) :: no_return()
|
||||
def start(socket) do
|
||||
{:ok, spawn(fn ->
|
||||
Process.set_label("ConnectionReceiver for #{inspect(socket)}")
|
||||
{:ok, pid} = Amethyst.ConnectionHandler.start_link(socket, Amethyst.ConnectionState.Handshake, 0)
|
||||
receive(socket, pid)
|
||||
end)}
|
||||
end
|
||||
|
||||
@spec start_link(:gen_tcp.socket()) :: no_return()
|
||||
def start_link(socket) do
|
||||
{:ok, spawn_link(fn ->
|
||||
Process.set_label("ConnectionReceiver for #{inspect(socket)}")
|
||||
{:ok, pid} = Amethyst.ConnectionHandler.start_link(socket, Amethyst.ConnectionState.Handshake, 0)
|
||||
receive(socket, pid)
|
||||
end)}
|
||||
end
|
||||
|
||||
@spec receive(:gen_tcp.socket(), pid()) :: no_return()
|
||||
def receive(socket, sender) do
|
||||
case get_packet(socket) do
|
||||
:closed -> send(sender, :closed)
|
||||
Process.exit(self(), :normal)
|
||||
{:error, error} -> Logger.error("Error reading packet: #{error}")
|
||||
{id, data} -> send(sender, {:packet, id, data})
|
||||
end
|
||||
receive(socket, sender)
|
||||
end
|
||||
|
||||
def get_packet(client) do
|
||||
case get_varint(client, "") do
|
||||
:closed -> :closed
|
||||
{:error, error} -> {:error, error}
|
||||
{[length], ""} ->
|
||||
recv = :gen_tcp.recv(client, length)
|
||||
case recv do
|
||||
{:ok, full_packet} -> ({[id], data} = Read.start(full_packet) |> Read.varint() |> Read.stop()
|
||||
{id, data})
|
||||
{:error, :closed} -> :closed
|
||||
{:error, error} -> {:error, error}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp get_varint(client, acc) do
|
||||
case :gen_tcp.recv(client, 1) do
|
||||
{:ok, byte} -> case byte do
|
||||
<<0::1, _::7>> -> Read.start(acc <> byte) |> Read.varint() |> Read.stop()
|
||||
<<1::1, _::7>> -> get_varint(client, acc <> byte)
|
||||
end
|
||||
{:error, :closed} -> :closed
|
||||
{:error, error} -> {:error, error}
|
||||
end
|
||||
end
|
||||
end
|
131
apps/amethyst/lib/apps/game_coordinator.ex
Normal file
131
apps/amethyst/lib/apps/game_coordinator.ex
Normal file
@ -0,0 +1,131 @@
|
||||
# Amethyst - An experimental Minecraft server written in Elixir.
|
||||
# Copyright (C) 2024 KodiCraft
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
defmodule Amethyst.GameCoordinator do
|
||||
use GenServer
|
||||
|
||||
@moduledoc """
|
||||
The game coordinator is responsible for keeping track of active
|
||||
instances of each game and can create new ones on demand.
|
||||
"""
|
||||
require Logger
|
||||
|
||||
defmodule State do
|
||||
@moduledoc """
|
||||
This struct represents the state tracked by a Amethyst.GameCoordinator.State
|
||||
|
||||
It contains two fields: `gid` which represents the latest gid (game id) and must be incremented every
|
||||
time a game is created and `games` which is a map linking each gid to a Amethyst.GameCoordinator.Game
|
||||
"""
|
||||
alias Amethyst.GameCoordinator.Game
|
||||
defstruct gid: 0, games: %{0 => Game}
|
||||
end
|
||||
defmodule Game do
|
||||
@moduledoc """
|
||||
This module represents an individual game instance that is currently active. Each game has a module
|
||||
which defines callbacks for various events, a map containing pids to the game's state (called references
|
||||
or refs) and optional metadata.
|
||||
"""
|
||||
defstruct mod: :none, refs: %{}, opts: [], gid: 0
|
||||
end
|
||||
|
||||
@spec start_link(State) :: {:ok, pid()}
|
||||
def start_link(initial) do
|
||||
GenServer.start_link(__MODULE__, initial, name: {:global, __MODULE__})
|
||||
end
|
||||
|
||||
@impl true
|
||||
@spec init(State) :: {:ok, State}
|
||||
def init(initial) do
|
||||
{:ok, initial}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:find, type}, _from, state) do
|
||||
{game, state} = _find(type, state)
|
||||
{:reply, game, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:create, type}, _from, state) do
|
||||
{game, state} = _create(type, state)
|
||||
{:reply, game, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:find_or_create, type}, from, state) do
|
||||
{game, state} = _find(type, state)
|
||||
case game do
|
||||
nil -> handle_call({:create, type}, from, state)
|
||||
some -> {:reply, some, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:remove, gid}, state) do
|
||||
{_, state} = _remove(gid, state)
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@spec _create(atom(), State.t()) :: {Game.t(), State.t()}
|
||||
defp _create(type, state) do
|
||||
# Create a DynamicSupervisor for this game
|
||||
{:ok, game_supervisor_pid} = DynamicSupervisor.start_child(
|
||||
{:via, PartitionSupervisor, {Amethyst.GameMetaSupervisor, type}},
|
||||
DynamicSupervisor
|
||||
)
|
||||
{:ok, task_supervisor_pid} = DynamicSupervisor.start_child(
|
||||
game_supervisor_pid,
|
||||
Task.Supervisor
|
||||
)
|
||||
# TODO: Instantiation can fail (including with an exception), and if it does the entire GameCoordinator goes down
|
||||
# We should gracefully handle situations where we cannot create a game.
|
||||
{:ok, refs} = type.instantiate(game_supervisor_pid)
|
||||
refs = refs |> Map.put(:game_supervisor, game_supervisor_pid) |> Map.put(:task_supervisor, task_supervisor_pid) |> Map.put(:game_coordinator, self())
|
||||
game = %Game{
|
||||
mod: type, refs: refs, opts: [], gid: state.gid
|
||||
}
|
||||
games = state.games |> Map.put(state.gid, game)
|
||||
Logger.info("Created new game of type #{inspect(type)} with gid #{state.gid}")
|
||||
{game, %State{gid: state.gid + 1, games: games}}
|
||||
end
|
||||
|
||||
defp _find(type, state) do
|
||||
case state.games |> Enum.find(fn {_, game} -> game.mod == type && game.mod.joinable?(game.refs) end) do
|
||||
nil -> {nil, state}
|
||||
{_, game} -> {game, state}
|
||||
end
|
||||
end
|
||||
|
||||
defp _remove(gid, state) do
|
||||
games = state.games |> Map.delete(gid)
|
||||
Logger.info("Removed game with gid #{gid}")
|
||||
{games, %State{state | games: games}}
|
||||
end
|
||||
|
||||
def create(type) when is_atom(type) do
|
||||
GenServer.call({:global, __MODULE__}, {:create, type})
|
||||
end
|
||||
def find(type) when is_atom(type) do
|
||||
GenServer.call({:global, __MODULE__}, {:find, type})
|
||||
end
|
||||
def find_or_create(type) when is_atom(type) do
|
||||
GenServer.call({:global, __MODULE__}, {:find_or_create, type})
|
||||
end
|
||||
def remove(gid) when is_integer(gid) do
|
||||
GenServer.cast({:global, __MODULE__}, {:remove, gid})
|
||||
end
|
||||
end
|
@ -21,7 +21,7 @@ defmodule Amethyst.TCPListener do
|
||||
"""
|
||||
|
||||
def accept(port) do
|
||||
{: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}")
|
||||
loop_acceptor(socket)
|
||||
end
|
||||
@ -29,7 +29,7 @@ defmodule Amethyst.TCPListener do
|
||||
@spec loop_acceptor(socket :: :gen_tcp.socket()) :: no_return()
|
||||
defp loop_acceptor(socket) do
|
||||
{:ok, client} = :gen_tcp.accept(socket)
|
||||
{:ok, pid} = 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)
|
||||
Logger.info("Received connection from #{inspect(client)}, assigned to PID #{inspect(pid)}")
|
||||
loop_acceptor(socket)
|
||||
|
@ -35,6 +35,10 @@ defmodule Amethyst.Minecraft.Write do
|
||||
end
|
||||
end
|
||||
|
||||
def raw(value) do
|
||||
value
|
||||
end
|
||||
|
||||
def byte(value) when value in -128..127 do
|
||||
<<value::8-signed-big>>
|
||||
end
|
||||
@ -98,6 +102,14 @@ defmodule Amethyst.Minecraft.Write do
|
||||
<<varint(byte_size(value))::binary, value::binary>>
|
||||
end
|
||||
|
||||
def json(value) do
|
||||
string(Jason.encode!(value))
|
||||
end
|
||||
|
||||
def byte_array(value) do
|
||||
<<varint(byte_size(value))::binary, value::binary>>
|
||||
end
|
||||
|
||||
def position({x, y, z}) do
|
||||
<<x::signed-big-26, z::signed-big-26, y::signed-big-12>>
|
||||
end
|
||||
@ -123,6 +135,10 @@ defmodule Amethyst.Minecraft.Write do
|
||||
v -> bool(true) <> callback.(v)
|
||||
end
|
||||
end
|
||||
|
||||
def nbt(value) do
|
||||
Amethyst.NBT.Write.write_net(value)
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Amethyst.Minecraft.Read do
|
||||
@ -243,4 +259,11 @@ defmodule Amethyst.Minecraft.Read do
|
||||
<<value::binary-size(length), rest::binary>> = rest
|
||||
{[value | acc], rest, :reversed}
|
||||
end
|
||||
def json({acc, data, :reversed}) do
|
||||
{[value], rest, :reversed} = string({[], data, :reversed})
|
||||
{[Jason.decode!(value) | acc], rest, :reversed}
|
||||
end
|
||||
def raw({acc, data, :reversed}) do
|
||||
{[data | acc], "", :reversed}
|
||||
end
|
||||
end
|
||||
|
107
apps/amethyst/lib/game.ex
Normal file
107
apps/amethyst/lib/game.ex
Normal file
@ -0,0 +1,107 @@
|
||||
# Amethyst - An experimental Minecraft server written in Elixir.
|
||||
# Copyright (C) 2024 KodiCraft
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
defmodule Amethyst.Game do
|
||||
@moduledoc """
|
||||
This behaviour should be implemented by any Amethyst Game. It additionally
|
||||
contains functions that the internal connection handler code uses to more
|
||||
conveniently call a game's callbacks.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
`instantiate/1` is called when a new instance of your game is created. You may start any additional
|
||||
processes you want under the `DynamicSupervisor` 'supervisor' and return a map with the PIDs to those
|
||||
processes, known as your references. Other callbacks in your game will receive these references and
|
||||
may use them to access and update state.
|
||||
"""
|
||||
@callback instantiate(supervisor :: pid()) :: {:ok, state_refs :: map()} | {:error, reason :: term}
|
||||
@doc """
|
||||
`login/3` is called when a new player logs into a game instance.
|
||||
You may either :accept or :reject the player for whatever reason, avoid
|
||||
rejecting the player in your default game as that will disconnect the player.
|
||||
|
||||
If you :accept the player, you must return a spawn position ({x, y, z}) and
|
||||
rotation ({yaw, pitch})
|
||||
|
||||
Note that if no new players can join for any reason, your game should return false from `joinable?/1`.
|
||||
|
||||
The PID received in 'from' will be the one that calls all callbacks
|
||||
caused directly by this player, such as their movement or interactions --
|
||||
You can expect that PID to never be used by any other player and that this same
|
||||
player will not use another PID until they disconnect from the server.
|
||||
|
||||
- 'from' is the PID of the player's connection process.
|
||||
- 'player_cfg' is a keyword list containing the configuration passed by the game client
|
||||
- 'state_refs' are your references (see `instantiate/1`)
|
||||
"""
|
||||
@callback login(from :: pid(), player_cfg :: keyword(), state_refs :: map()) :: {:accept, {x :: float(), y :: float(), z :: float()}, {yaw :: float(), pitch ::float()}} | :reject
|
||||
def login(%{:mod => mod, :refs => refs}, player_cfg) do
|
||||
mod.login(self(), player_cfg, refs)
|
||||
end
|
||||
@doc """
|
||||
`player_position/3` is called when a player moves. This function is called with the absolute coordinates
|
||||
that the player client expects. TODO: Teleport Player API.
|
||||
|
||||
- 'from' is the PID of the player's connection process (see `login/3`).
|
||||
- 'x', 'y' and 'z' are the absolute coordinates that the player wants to move to.
|
||||
- `state_refs` are your references (see `instantiate/1`)
|
||||
"""
|
||||
@callback player_position(from :: pid(), {x :: float(), y :: float(), z :: float()}, state_refs :: map()) :: :ok
|
||||
def player_position(%{:mod => mod, :refs => refs}, {x, y, z}) do
|
||||
mod.player_position(self(), {x, y, z}, refs)
|
||||
end
|
||||
@doc """
|
||||
`player_rotation/3` is called when a player rotates. This function is called with the absolute angles
|
||||
that the player client expects. TODO: Teleport Player API.
|
||||
|
||||
- 'from' is the PID of the player's connection process (see `login/3`).
|
||||
- 'yaw' and 'pitch' are the angles the player expects to rotate to. These are in Minecraft's rotation format.
|
||||
- `state_refs` are your references (see `instantiate/1`)
|
||||
"""
|
||||
@callback player_rotation(from :: pid(), {yaw :: float(), pitch :: float()}, state_refs :: map()) :: :ok
|
||||
def player_rotation(%{:mod => mod, :refs => refs}, {yaw, pitch}) do
|
||||
mod.player_rotation(self(), {yaw, pitch}, refs)
|
||||
end
|
||||
@doc """
|
||||
`accept_teleport/3` is called when a client accepts a teleportation as sent by the Synchronize Player Position
|
||||
packet (TODO: Teleport Player API). This lets you know that the client is now where you expect it to be.
|
||||
|
||||
- 'from' is the PID of the player's connection process (see `login/3`).
|
||||
- 'id' is the teleport ID (TODO: Teleport Player API)
|
||||
- 'state_refs' are your references (see `instantiate/1`)
|
||||
"""
|
||||
@callback accept_teleport(from :: pid(), id :: integer(), state_refs :: map()) :: :ok
|
||||
def accept_teleport(%{:mod => mod, :refs => refs}, id) do
|
||||
mod.accept_teleport(self(), id, refs)
|
||||
end
|
||||
@doc """
|
||||
The terrain of a specific chunk column. This is automatically used to load chunks for a player.
|
||||
|
||||
For now, this data must be formatted as a 3D list, indexed as [y][z][x].
|
||||
"""
|
||||
@callback chunk(from :: pid(), {x :: integer(), z :: integer()}, state_refs :: map()) :: [[[pos_integer()]]]
|
||||
def chunk(%{:mod => mod, :refs => refs}, pos) do
|
||||
mod.chunk(self(), pos, refs)
|
||||
end
|
||||
@doc """
|
||||
Whether or not this game instance can be joined by a new player. This should include basic logic such as
|
||||
if joining makes sense, for instance if the game is full or if the game has already started.
|
||||
"""
|
||||
@callback joinable?(state_refs :: map()) :: boolean()
|
||||
def joinable?(%{:mod => mod, :refs => refs}) do
|
||||
mod.joinable?(refs)
|
||||
end
|
||||
end
|
@ -27,6 +27,20 @@ defmodule Amethyst.NBT.Write do
|
||||
<<type_id(type)::size(8), payload(type, value)::binary>>
|
||||
end
|
||||
|
||||
def check_type({:byte, value}) when is_integer(value) and value in -128..127, do: true
|
||||
def check_type({:short, value}) when is_integer(value) and value in -32_768..32_767, do: true
|
||||
def check_type({:int, value}) when is_integer(value) and value in -2_147_483_648..2_147_483_647, do: true
|
||||
def check_type({:long, value}) when is_integer(value) and value in -9_223_372_036_854_775_808..9_223_372_036_854_775_807, do: true
|
||||
def check_type({:float, value}) when is_float(value), do: true
|
||||
def check_type({:double, value}) when is_float(value), do: true
|
||||
def check_type({:byte_array, values}) when is_list(values), do: Enum.all?(values, &is_integer/1)
|
||||
def check_type({:string, value}) when is_binary(value), do: true
|
||||
def check_type({:list, {type, values}}) when is_list(values), do: Enum.all?(values, &check_type({type, &1}))
|
||||
def check_type({:compound, values}) when is_map(values), do: Enum.all?(values, fn {_name, {type, value}} -> check_type({type, value}) end)
|
||||
def check_type({:int_array, values}) when is_list(values), do: Enum.all?(values, &is_integer/1)
|
||||
def check_type({:long_array, values}) when is_list(values), do: Enum.all?(values, &is_integer/1)
|
||||
def check_type(_), do: false
|
||||
|
||||
defp type_id(:end), do: 0
|
||||
defp type_id(:byte), do: 1
|
||||
defp type_id(:short), do: 2
|
||||
|
@ -1,355 +0,0 @@
|
||||
# Amethyst - An experimental Minecraft server written in Elixir.
|
||||
# Copyright (C) 2024 KodiCraft
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
defmodule Amethyst.Server.Configuration do
|
||||
@moduledoc """
|
||||
This module contains the logic for the Configuration stage of the server.
|
||||
"""
|
||||
require Logger
|
||||
use Amethyst.Server
|
||||
|
||||
alias Amethyst.Minecraft.Read
|
||||
alias Amethyst.Minecraft.Write
|
||||
|
||||
@impl true
|
||||
def init(state) do
|
||||
state
|
||||
end
|
||||
|
||||
## DESERIALIZATION
|
||||
@impl true
|
||||
# Client Information https://wiki.vg/Protocol#Client_Information
|
||||
def deserialize(0x00, data) do
|
||||
{[locale, view_dist, chat_mode, chat_colors, displayed_skin_parts, main_hand, text_filtering, allow_listing], ""} =
|
||||
Read.start(data) |> Read.string |> Read.byte |> Read.varint |> Read.bool |> Read.ubyte |> Read.varint |> Read.bool |> Read.bool |> Read.stop
|
||||
chat_mode = case chat_mode do
|
||||
0 -> :enabled
|
||||
1 -> :commands_only
|
||||
2 -> :hidden
|
||||
_ -> raise RuntimeError, "Unknown chat mode #{chat_mode}"
|
||||
end
|
||||
main_hand = case main_hand do
|
||||
0 -> :left
|
||||
1 -> :right
|
||||
_ -> raise RuntimeError, "Unknown main hand #{main_hand}"
|
||||
end
|
||||
{:client_information, locale, view_dist, chat_mode, chat_colors, displayed_skin_parts, main_hand, text_filtering, allow_listing}
|
||||
end
|
||||
# Cookie Response https://wiki.vg/Protocol#Cookie_Response_(configuration)
|
||||
def deserialize(0x01, data) do
|
||||
{[key, exists], rest} = Read.start(data) |> Read.string |> Read.bool |> Read.stop
|
||||
if exists do
|
||||
{[length], rest} = Read.start(rest) |> Read.varint |> Read.stop
|
||||
{[data], _} = Read.start(rest) |> Read.raw(length) |> Read.stop
|
||||
{:cookie_response, key, data}
|
||||
else
|
||||
{:cookie_response, key, nil}
|
||||
end
|
||||
end
|
||||
# Serverbound Plugin Message https://wiki.vg/Protocol#Serverbound_Plugin_Message_(configuration)
|
||||
def deserialize(0x02, data) do
|
||||
{[channel], rest} = Read.start(data) |> Read.string |> Read.stop
|
||||
{:serverbound_plugin_message, channel, rest}
|
||||
end
|
||||
# Acknowledge Finish Configuration https://wiki.vg/Protocol#Acknowledge_Finish_Configuration
|
||||
def deserialize(0x03, "") do
|
||||
{:acknowledge_finish_configuration}
|
||||
end
|
||||
# Serverbound Keep Alive https://wiki.vg/Protocol#Serverbound_Keep_Alive_(configuration)
|
||||
def deserialize(0x04, data) do
|
||||
{[id], ""} = Read.start(data) |> Read.long |> Read.stop
|
||||
{:serverbound_keep_alive, id}
|
||||
end
|
||||
# Pong https://wiki.vg/Protocol#Pong_(configuration)
|
||||
def deserialize(0x05, data) do
|
||||
{[id], ""} = Read.start(data) |> Read.int |> Read.stop
|
||||
{:pong, id}
|
||||
end
|
||||
# Resource Pack Response https://wiki.vg/Protocol#Resource_Pack_Response_(configuration)
|
||||
def deserialize(0x06, data) do
|
||||
{[uuid, result], ""} = Read.start(data) |> Read.uuid |> Read.varint |> Read.stop
|
||||
result = case result do
|
||||
0 -> :successfully_downloaded
|
||||
1 -> :declined
|
||||
2 -> :failed_to_download
|
||||
3 -> :accepted
|
||||
4 -> :downloaded
|
||||
5 -> :invalid_url
|
||||
6 -> :failed_to_reload
|
||||
7 -> :discarded
|
||||
_ -> raise RuntimeError, "Unknown resource pack response #{result}"
|
||||
end
|
||||
{:resource_pack_response, uuid, result}
|
||||
end
|
||||
# Serverbound Known Packs https://wiki.vg/Protocol#Serverbound_Known_Packs
|
||||
def deserialize(0x07, data) do
|
||||
{[count], rest} = Read.start(data) |> Read.varint |> Read.stop
|
||||
{packs, _} = Enum.reduce(1..count, {[], rest}, fn _, {acc, rest} ->
|
||||
{[namespace, id, version], rest} = Read.start(rest) |> Read.string |> Read.string |> Read.string |> Read.stop
|
||||
{[{namespace, id, version} | acc], rest}
|
||||
end)
|
||||
{:serverbound_known_packs, packs}
|
||||
end
|
||||
def deserialize(type, _) do
|
||||
raise RuntimeError, "Got unknown packet type #{type}!"
|
||||
end
|
||||
|
||||
## SERIALIZATION
|
||||
@impl true
|
||||
# Cookie Request https://wiki.vg/Protocol#Cookie_Request_(configuration)
|
||||
def serialize({:cookie_request, id}) do
|
||||
Write.varint(0x00) <> Write.string(id)
|
||||
end
|
||||
# Clientbound Plugin Message https://wiki.vg/Protocol#Clientbound_Plugin_Message_(configuration)
|
||||
def serialize({:clientbound_plugin_message, channel, data}) do
|
||||
Write.varint(0x01) <> Write.string(channel) <> data
|
||||
end
|
||||
# Disconnect https://wiki.vg/Protocol#Disconnect_(configuration)
|
||||
def serialize({:disconnect, reason}) do
|
||||
Write.varint(0x02) <> Write.string(reason)
|
||||
end
|
||||
# Finish Configuration https://wiki.vg/Protocol#Finish_Configuration
|
||||
def serialize({:finish_configuration}) do
|
||||
Write.varint(0x03)
|
||||
end
|
||||
# Clientbound Keep Alive https://wiki.vg/Protocol#Clientbound_Keep_Alive_(configuration)
|
||||
def serialize({:clientbound_keep_alive, id}) do
|
||||
Write.varint(0x04) <> <<id::64-big-signed>>
|
||||
end
|
||||
# Ping https://wiki.vg/Protocol#Ping_(configuration)
|
||||
def serialize({:ping, id}) do
|
||||
Write.varint(0x05) <> <<id::32-big-signed>>
|
||||
end
|
||||
# Reset Chat https://wiki.vg/Protocol#Reset_Chat
|
||||
def serialize({:reset_chat}) do
|
||||
Write.varint(0x06)
|
||||
end
|
||||
# Registry Data https://wiki.vg/Protocol#Registry_Data
|
||||
def serialize({:registry_data, id, entries}) do
|
||||
Write.varint(0x07) <> Write.string(id) <> Write.varint(length(entries)) <> Enum.map_join(entries, "", fn {name, nbt} ->
|
||||
Write.string(name) <>
|
||||
if nbt == nil do
|
||||
Write.bool(false)
|
||||
else
|
||||
Write.bool(true) <> Amethyst.NBT.Write.write_net(nbt)
|
||||
end
|
||||
end)
|
||||
end
|
||||
# Remove Resource Pack https://wiki.vg/Protocol#Remove_Resource_Pack_(configuration)
|
||||
def serialize({:remove_resource_pack, id}) do
|
||||
Write.option(id, &Write.string/1)
|
||||
end
|
||||
# Add Resource Pack https://wiki.vg/Protocol#Add_Resource_Pack_(configuration)
|
||||
def serialize({:add_resource_pack, id, url, hash, forced, msg}) do
|
||||
Write.varint(0x09) <> Write.string(id) <> Write.string(url) <> Write.string(hash) <>
|
||||
Write.bool(forced) <> Write.option(msg, &Write.string/1)
|
||||
end
|
||||
# Store Cookie https://wiki.vg/Protocol#Store_Cookie_(configuration)
|
||||
def serialize({:store_cookie, id, data}) do
|
||||
Write.varint(0x0A) <> Write.string(id) <> Write.string(data)
|
||||
end
|
||||
# Transfer https://wiki.vg/Protocol#Transfer_(configuration)
|
||||
def serialize({:transfer, addr, port}) do
|
||||
Write.varint(0x0B) <> Write.string(addr) <> Write.varint(port)
|
||||
end
|
||||
# Feature Flags https://wiki.vg/Protocol#Feature_Flags
|
||||
def serialize({:feature_flags, flags}) do
|
||||
Write.varint(0x0C) <> Write.varint(length(flags)) <> Write.list(flags, &Write.string/1)
|
||||
end
|
||||
# Update Tags https://wiki.vg/Protocol#Update_Tags
|
||||
def serialize({:update_tags, tags}) do
|
||||
Write.varint(0x0D) <> Write.varint(length(tags)) <>
|
||||
Enum.reduce(tags, "", &serialize_tag/2)
|
||||
end
|
||||
# Clientbound Known Packs https://wiki.vg/Protocol#Clientbound_Known_Packs
|
||||
def serialize({:clientbound_known_packs, packs}) do
|
||||
Write.varint(0x0E) <> Write.varint(length(packs)) <>
|
||||
Write.list(packs, fn {namespace, id, version} -> Write.string(namespace) <> Write.string(id) <> Write.string(version) end)
|
||||
end
|
||||
# Custom Report Details https://wiki.vg/Protocol#Custom_Report_Details
|
||||
def serialize({:custom_report_details, details}) do
|
||||
Write.varint(0x0F) <> Write.varint(length(details)) <>
|
||||
Write.list(details, fn {id, data} -> Write.string(id) <> Write.string(data) end)
|
||||
end
|
||||
# Server Links https://wiki.vg/Protocol#Server_Links_(configuration)
|
||||
def serialize({:server_links, links}) do
|
||||
Write.varint(0x10) <> Write.varint(length(links)) <>
|
||||
Write.list(links, fn {label, url} -> serialize_link_label(label) <> Write.string(url) end)
|
||||
end
|
||||
def serialize(packet) do
|
||||
raise ArgumentError, "Tried serializing unknown packet #{inspect(packet)}"
|
||||
end
|
||||
|
||||
defp serialize_tag({id, elements}, acc) do
|
||||
acc <> Write.string(id) <> Write.varint(length(elements)) <> serialize_elements(elements)
|
||||
end
|
||||
defp serialize_elements(elements) do
|
||||
Write.list(elements, fn {id, ids} ->
|
||||
Write.string(id) <> Write.varint(length(ids)) <>
|
||||
Write.list(ids, &Write.varint/1)
|
||||
end)
|
||||
end
|
||||
defp serialize_link_label(:bug_report) do
|
||||
<<0x01>> <> Write.varint(0x00)
|
||||
end
|
||||
defp serialize_link_label(:community_guidelines) do
|
||||
<<0x01>> <> Write.varint(0x01)
|
||||
end
|
||||
defp serialize_link_label(:support) do
|
||||
<<0x01>> <> Write.varint(0x02)
|
||||
end
|
||||
defp serialize_link_label(:status) do
|
||||
<<0x01>> <> Write.varint(0x03)
|
||||
end
|
||||
defp serialize_link_label(:feedback) do
|
||||
<<0x01>> <> Write.varint(0x04)
|
||||
end
|
||||
defp serialize_link_label(:community) do
|
||||
<<0x01>> <> Write.varint(0x05)
|
||||
end
|
||||
defp serialize_link_label(:website) do
|
||||
<<0x01>> <> Write.varint(0x06)
|
||||
end
|
||||
defp serialize_link_label(:forums) do
|
||||
<<0x01>> <> Write.varint(0x07)
|
||||
end
|
||||
defp serialize_link_label(:news) do
|
||||
<<0x01>> <> Write.varint(0x08)
|
||||
end
|
||||
defp serialize_link_label(:announcements) do
|
||||
<<0x01>> <> Write.varint(0x09)
|
||||
end
|
||||
defp serialize_link_label(other) do
|
||||
<<0x00>> <> Write.string(other)
|
||||
end
|
||||
|
||||
## HANDLING
|
||||
@impl true
|
||||
# Client Information https://wiki.vg/Protocol#Client_Information
|
||||
def handle({:client_information, locale, v_dist, chat_mode, chat_colors, displayed_skin_parts, main_hand, text_filtering, allow_listing}, client, state) do
|
||||
state = state |> Keyword.put(:locale, locale) |> Keyword.put(:view_dist, v_dist) |> Keyword.put(:chat_mode, chat_mode) |>
|
||||
Keyword.put(:chat_colors, chat_colors) |> Keyword.put(:displayed_skin_parts, displayed_skin_parts) |> Keyword.put(:main_hand, main_hand) |>
|
||||
Keyword.put(:text_filtering, text_filtering) |> Keyword.put(:allow_listing, allow_listing)
|
||||
# TODO: Here we should create the game handling task for this player and give it
|
||||
# this data.
|
||||
transmit({:clientbound_plugin_message, "minecraft:brand", Write.string("amethyst")}, client)
|
||||
transmit({:clientbound_known_packs, [{"minecraft", "core", "1.21"}, {"minecraft", "dimension_type", "1.21"}]}, client)
|
||||
{:ok, state}
|
||||
end
|
||||
# Serverbound Known Packs https://wiki.vg/Protocol#Serverbound_Known_Packs
|
||||
def handle({:serverbound_known_packs, _packs}, client, state) do
|
||||
# L + ratio + don't care + didn't ask + finish configuration
|
||||
import Amethyst.NBT.Write
|
||||
# TODO: This shouldn't be hard-coded but we obviously don't know what we will need until we have game handling
|
||||
# This can at least be followed as a "minimum" of what we need to send for the client not to complain
|
||||
transmit({:registry_data, "minecraft:dimension_type", [{"amethyst:basic", compound(%{
|
||||
"has_skylight" => byte(1),
|
||||
"has_ceiling" => byte(0),
|
||||
"ultrawarm" => byte(0),
|
||||
"natural" => byte(1),
|
||||
"coordinate_scale" => float(1.0),
|
||||
"bed_works" => byte(1),
|
||||
"respawn_anchor_works" => byte(1),
|
||||
"min_y" => int(0),
|
||||
"height" => int(256),
|
||||
"logical_height" => int(256),
|
||||
"infiniburn" => string("#"),
|
||||
"effects" => string("minecraft:overworld"),
|
||||
"ambient_light" => float(0.0),
|
||||
"piglin_safe" => byte(0),
|
||||
"has_raids" => byte(1),
|
||||
"monster_spawn_light_level" => int(0),
|
||||
"monster_spawn_block_light_limit" => int(0)
|
||||
})}]}, client)
|
||||
transmit({:registry_data, "minecraft:painting_variant", [{"minecraft:kebab", compound(%{
|
||||
"asset_id" => string("minecraft:kebab"),
|
||||
"height" => int(1),
|
||||
"width" => int(1),
|
||||
})}]}, client)
|
||||
transmit({:registry_data, "minecraft:wolf_variant", [{"minecraft:wolf_ashen", compound(%{
|
||||
"wild_texture" => string("minecraft:entity/wolf/wolf_ashen"),
|
||||
"tame_texture" => string("minecraft:entity/wolf/wolf_ashen_tame"),
|
||||
"angry_texture" => string("minecraft:entity/wolf/wolf_ashen_angry"),
|
||||
"biomes" => string("amethyst:basic"),
|
||||
})}]}, client)
|
||||
# https://gist.github.com/WinX64/ab8c7a8df797c273b32d3a3b66522906 minecraft:plains
|
||||
basic_biome = compound(%{
|
||||
"effects" => compound(%{
|
||||
"sky_color" => int(7907327),
|
||||
"water_fog_color" => int(329011),
|
||||
"fog_color" => int(12638463),
|
||||
"water_color" => int(4159204),
|
||||
"mood_sound" => compound(%{
|
||||
"tick_delay" => int(6000),
|
||||
"offset" => float(2.0),
|
||||
"sound" => string("minecraft:ambient.cave"),
|
||||
"block_search_extent" => int(8)
|
||||
}),
|
||||
}),
|
||||
"has_precipitation" => byte(1),
|
||||
"temperature" => float(0.8),
|
||||
"downfall" => float(0.4),
|
||||
})
|
||||
transmit({:registry_data, "minecraft:worldgen/biome", [
|
||||
{"amethyst:basic", basic_biome}, {"minecraft:plains", basic_biome}
|
||||
]}, client)
|
||||
# holy fucking shit
|
||||
generic_damage = compound(%{
|
||||
"scaling" => string("when_caused_by_living_non_player"),
|
||||
"exhaustion" => float(0.0),
|
||||
"message_id" => string("generic")
|
||||
})
|
||||
transmit({:registry_data, "minecraft:damage_type", [
|
||||
{"minecraft:in_fire", generic_damage}, {"minecraft:campfire", generic_damage}, {"minecraft:lightning_bolt", generic_damage},
|
||||
{"minecraft:on_fire", generic_damage}, {"minecraft:lava", generic_damage}, {"minecraft:hot_floor", generic_damage},
|
||||
{"minecraft:in_wall", generic_damage}, {"minecraft:cramming", generic_damage}, {"minecraft:drown", generic_damage},
|
||||
{"minecraft:starve", generic_damage}, {"minecraft:cactus", generic_damage}, {"minecraft:fall", generic_damage},
|
||||
{"minecraft:fly_into_wall", generic_damage}, {"minecraft:out_of_world", generic_damage}, {"minecraft:generic", generic_damage},
|
||||
{"minecraft:magic", generic_damage}, {"minecraft:wither", generic_damage}, {"minecraft:dragon_breath", generic_damage},
|
||||
{"minecraft:dry_out", generic_damage}, {"minecraft:sweet_berry_bush", generic_damage}, {"minecraft:freeze", generic_damage},
|
||||
{"minecraft:stalagmite", generic_damage}, {"minecraft:outside_border", generic_damage}, {"minecraft:generic_kill", generic_damage},
|
||||
]}, client)
|
||||
transmit({:finish_configuration}, client)
|
||||
{:ok, state}
|
||||
end
|
||||
# Acknowledge Finish Configuration https://wiki.vg/Protocol#Acknowledge_Finish_Configuration
|
||||
def handle({:acknowledge_finish_configuration}, client, state) do
|
||||
# TODO: All of this stuff should obviously not be hardcoded here
|
||||
Amethyst.Server.Play.transmit({:login,
|
||||
0, false, ["minecraft:overworld"], 0, 16, 16, false, true, true, 0,
|
||||
"minecraft:overworld", <<0::64>>, :spectator, nil, false, true, nil, 0, false
|
||||
}, client)
|
||||
Amethyst.Server.Play.serve(client, state)
|
||||
end
|
||||
# Serverbound Plugin Message https://wiki.vg/Protocol#Serverbound_Plugin_Message_(configuration)
|
||||
def handle({:serverbound_plugin_message, channel, data}, client, state) do
|
||||
handle_plugin_message(channel, data, client, state)
|
||||
end
|
||||
def handle(tuple, state) do
|
||||
Logger.error("Unhandled but known packet #{elem(tuple, 0)}")
|
||||
{:unhandled, state}
|
||||
end
|
||||
defp handle_plugin_message("minecraft:brand", data, _client, state) do
|
||||
{[brand], ""} = Read.start(data) |> Read.string |> Read.stop
|
||||
Logger.info("Client using brand: #{brand}")
|
||||
{:ok, Keyword.put(state, :brand, brand)}
|
||||
end
|
||||
defp handle_plugin_message("amethyst:hello", _data, client, state) do
|
||||
Logger.info("Client is Amethyst aware! Hello!")
|
||||
transmit({:clientbound_plugin_message, "amethyst:hello", ""}, client)
|
||||
{:ok, Keyword.put(state, :knows_amethyst, true)}
|
||||
end
|
||||
end
|
@ -1,87 +0,0 @@
|
||||
# Amethyst - An experimental Minecraft server written in Elixir.
|
||||
# Copyright (C) 2024 KodiCraft
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
defmodule Amethyst.Server.Generic do
|
||||
@moduledoc """
|
||||
This module includes generic logic which may be used by all stages of the server, including,
|
||||
for instance, listening for packets.
|
||||
"""
|
||||
|
||||
alias Amethyst.Minecraft.Read
|
||||
|
||||
def get_packet(client) do
|
||||
{[length], ""} = get_varint(client, "")
|
||||
recv = :gen_tcp.recv(client, length)
|
||||
case recv do
|
||||
{:ok, full_packet} -> ({[id], data} = Read.start(full_packet) |> Read.varint() |> Read.stop()
|
||||
{id, data})
|
||||
{:error, :closed} -> raise RuntimeError, "TODO: Handle disconnections reasonably"
|
||||
{:error, error} -> raise RuntimeError, "An error has occured while waiting on a packet: #{error}"
|
||||
end
|
||||
end
|
||||
|
||||
defp get_varint(client, acc) do
|
||||
{:ok, byte} = :gen_tcp.recv(client, 1)
|
||||
case byte do
|
||||
<<0::1, _::7>> -> Read.start(acc <> byte) |> Read.varint() |> Read.stop()
|
||||
<<1::1, _::7>> -> get_varint(client, acc <> byte)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defmodule Amethyst.Server do
|
||||
@moduledoc """
|
||||
This module includes shared boilerplate code for all stages of the server.
|
||||
"""
|
||||
require Logger
|
||||
|
||||
@callback init(any()) :: any()
|
||||
@callback deserialize(integer(), binary()) :: any()
|
||||
@callback serialize(any()) :: binary()
|
||||
@callback handle(any(), :gen_tcp.socket(), any()) :: {:ok, any()} | {:unhandled, any()}
|
||||
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
@behaviour Amethyst.Server
|
||||
|
||||
@spec serve(:gen_tcp.socket(), any()) :: no_return()
|
||||
def serve(client, state \\ []) do
|
||||
Logger.debug("#{__MODULE__} serving client #{inspect(client)}")
|
||||
serve_loop(client, init(state))
|
||||
end
|
||||
|
||||
defp serve_loop(client, state) do
|
||||
{id, data} = Amethyst.Server.Generic.get_packet(client)
|
||||
Logger.debug("State: #{inspect(state)}")
|
||||
packet = deserialize(id, data)
|
||||
Logger.debug("Got packet #{inspect(packet)}")
|
||||
{result, state} = handle(packet, client, state)
|
||||
if result != :ok do
|
||||
Logger.warning("Handler returned result #{result}")
|
||||
end
|
||||
serve_loop(client, state)
|
||||
end
|
||||
|
||||
def transmit(packet, client) do
|
||||
Logger.debug("Transmitting #{inspect(packet)}")
|
||||
data = serialize(packet)
|
||||
length = byte_size(data) |> Amethyst.Minecraft.Write.varint()
|
||||
Logger.debug("Sending #{inspect(length <> data)}")
|
||||
:gen_tcp.send(client, length <> data)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -1,72 +0,0 @@
|
||||
# Amethyst - An experimental Minecraft server written in Elixir.
|
||||
# Copyright (C) 2024 KodiCraft
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
defmodule Amethyst.Server.Handshake do
|
||||
@moduledoc """
|
||||
This module contains the logic for the Handshake stage of the server.
|
||||
"""
|
||||
require Logger
|
||||
use Amethyst.Server
|
||||
|
||||
alias Amethyst.Minecraft.Read
|
||||
|
||||
@impl true
|
||||
def init(state) do
|
||||
state
|
||||
end
|
||||
|
||||
## DESERIALIZATION
|
||||
@impl true
|
||||
# Handshake https://wiki.vg/Protocol#Handshake
|
||||
@spec deserialize(0, binary()) ::
|
||||
{:handshake, any(), any(), any(), :login | :status | :transfer}
|
||||
def deserialize(0x00, <<data::binary>>) do
|
||||
{[ver, addr, port, next], ""} = Read.start(data) |> Read.varint() |> Read.string() |> Read.ushort() |> Read.varint() |> Read.stop()
|
||||
next = case next do
|
||||
1 -> :status
|
||||
2 -> :login
|
||||
3 -> :transfer
|
||||
_ -> raise RuntimeError, "Client requested moving to an unknown state!"
|
||||
end
|
||||
{:handshake, ver, addr, port, next}
|
||||
end
|
||||
def deserialize(type, _) do
|
||||
raise RuntimeError, "Got unknown packet type #{type}!"
|
||||
end
|
||||
|
||||
## SERIALIZATION
|
||||
@impl true
|
||||
def serialize(_) do
|
||||
raise RuntimeError, "No packets can be transmitted while still in the handshake stage!"
|
||||
end
|
||||
|
||||
## HANDLING
|
||||
@impl true
|
||||
# Handshake https://wiki.vg/Protocol#Handshake
|
||||
@spec handle(any(), any(), any()) :: no_return()
|
||||
def handle({:handshake, 767, addr, port, next}, client, state) do
|
||||
Logger.info("Got handshake, version 767 on #{addr}:#{port}. Wants to move to #{next}")
|
||||
case next do
|
||||
:status -> Amethyst.Server.Status.serve(client, state)
|
||||
:login -> Amethyst.Server.Login.serve(client, state)
|
||||
_ -> raise RuntimeError, "Unhandled move to next mode #{next}"
|
||||
end
|
||||
end
|
||||
def handle(tuple, _, state) do
|
||||
Logger.error("Unhandled but known packet #{elem(tuple, 0)}")
|
||||
{:unhandled, state}
|
||||
end
|
||||
end
|
@ -1,136 +0,0 @@
|
||||
# Amethyst - An experimental Minecraft server written in Elixir.
|
||||
# Copyright (C) 2024 KodiCraft
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
defmodule Amethyst.Server.Login do
|
||||
@moduledoc """
|
||||
This module contains the logic for the Login stage of the server.
|
||||
"""
|
||||
require Logger
|
||||
use Amethyst.Server
|
||||
|
||||
alias Amethyst.Minecraft.Read
|
||||
alias Amethyst.Minecraft.Write
|
||||
|
||||
@impl true
|
||||
def init(state) do
|
||||
state
|
||||
end
|
||||
|
||||
## DESERIALIZATION
|
||||
@impl true
|
||||
# Login Start https://wiki.vg/Protocol#Login_Start
|
||||
def deserialize(0x00, data) do
|
||||
{[name, uuid], ""} = Read.start(data) |> Read.string() |> Read.uuid() |> Read.stop()
|
||||
{:login_start, name, uuid}
|
||||
end
|
||||
# Encryption Response https://wiki.vg/Protocol#Encryption_Response
|
||||
def deserialize(0x01, data) do
|
||||
{[secret_length], rest} = Read.start(data) |> Read.varint() |> Read.stop()
|
||||
{[secret, verify_token_length], rest} = Read.start(rest) |> Read.raw(secret_length) |> Read.varint() |> Read.stop()
|
||||
{[verify_token], ""} = Read.start(rest) |> Read.raw(verify_token_length) |> Read.stop()
|
||||
{:encryption_response, secret, verify_token}
|
||||
end
|
||||
# Login Plugin Response https://wiki.vg/Protocol#Login_Plugin_Response
|
||||
def deserialize(0x02, data) do
|
||||
{[message_id, success], rest} = Read.start(data) |> Read.varint() |> Read.bool() |> Read.stop()
|
||||
if success do
|
||||
{:login_plugin_response, message_id, rest}
|
||||
else
|
||||
{:login_plugin_response, message_id, nil}
|
||||
end
|
||||
end
|
||||
# Login Acknowledged https://wiki.vg/Protocol#Login_Acknowledged
|
||||
def deserialize(0x03, "") do
|
||||
{:login_acknowledged}
|
||||
end
|
||||
# Cookie Response https://wiki.vg/Protocol#Cookie_Response_(login)
|
||||
def deserialize(0x04, data) do
|
||||
{[key, exists], rest} = Read.start(data) |> Read.string() |> Read.bool() |> Read.stop()
|
||||
if exists do
|
||||
{[length], rest} = Read.start(rest) |> Read.varint() |> Read.stop()
|
||||
{[data], _} = Read.start(rest) |> Read.raw(length) |> Read.stop()
|
||||
{:cookie_response, key, data}
|
||||
else
|
||||
{:cookie_response, key, nil}
|
||||
end
|
||||
end
|
||||
def deserialize(type, _) do
|
||||
raise RuntimeError, "Got unknown packet type #{type}!"
|
||||
end
|
||||
|
||||
## SERIALIZATION
|
||||
@impl true
|
||||
# Disconnect (login) https://wiki.vg/Protocol#Disconnect_(login)
|
||||
def serialize({:disconnect, reason}) do
|
||||
Write.varint(0x00) <> Write.string(reason)
|
||||
end
|
||||
# Encryption Request https://wiki.vg/Protocol#Encryption_Request
|
||||
def serialize({:encryption_request, server_id, pubkey, verify_token, auth}) do
|
||||
Write.varint(0x01) <>
|
||||
Write.string(server_id) <>
|
||||
Write.varint(byte_size(pubkey)) <> pubkey <>
|
||||
Write.varint(byte_size(verify_token)) <> verify_token <>
|
||||
Write.bool(auth)
|
||||
end
|
||||
# Login Success https://wiki.vg/Protocol#Login_Success
|
||||
def serialize({:login_success, uuid, username, props, strict}) do
|
||||
Write.varint(0x02) <> Write.uuid(uuid) <> Write.string(username) <> Write.varint(length(props)) <>
|
||||
Enum.reduce(props, "", fn {name, value, signature}, acc -> acc <> Write.string(name) <> Write.string(value) <> case signature do
|
||||
nil -> <<0x00>>
|
||||
signature -> <<0x01>> <> Write.string(signature)
|
||||
end end) <> Write.bool(strict)
|
||||
end
|
||||
# Set Compression https://wiki.vg/Protocol#Set_Compression
|
||||
def serialize({:set_compression, threshold}) do
|
||||
Write.varint(0x03) <> Write.varint(threshold)
|
||||
end
|
||||
# Login Plugin Request https://wiki.vg/Protocol#Login_Plugin_Request
|
||||
def serialize({:login_plugin_request, id, channel, data}) do
|
||||
Write.varint(0x04) <> Write.varint(id) <> Write.string(channel) <> data
|
||||
end
|
||||
# Cookie Request (login) https://wiki.vg/Protocol#Cookie_Request_(login)
|
||||
def serialize({:cookie_request_login, id}) do
|
||||
Write.varint(0x05) <> Write.string(id)
|
||||
end
|
||||
def serialize(packet) do
|
||||
raise ArgumentError, "Tried serializing unknown packet #{inspect(packet)}"
|
||||
end
|
||||
|
||||
## HANDLING
|
||||
@impl true
|
||||
# Login Start https://wiki.vg/Protocol#Login_Start
|
||||
def handle({:login_start, name, uuid}, client, state) do
|
||||
Logger.info("Logging in #{name} (#{uuid})")
|
||||
if Application.fetch_env!(:amethyst, :encryption) do
|
||||
raise RuntimeError, "Encryption is currently unsupported." # TODO: Implement encryption
|
||||
# verify_token = :crypto.strong_rand_bytes(4)
|
||||
# pubkey = Amethyst.Keys.get_pub()
|
||||
# auth = Application.fetch_env!(:amethyst, :auth)
|
||||
# transmit({:encryption_request, "amethyst", pubkey, verify_token, auth}, client) # This is broken for some reason? java.lang.IllegalStateException: Protocol Error
|
||||
else
|
||||
transmit({:login_success, uuid, name, [], false}, client)
|
||||
end
|
||||
{:ok, state}
|
||||
end
|
||||
def handle({:login_acknowledged}, client, state) do
|
||||
Amethyst.Server.Configuration.serve(client, state)
|
||||
{:ok, state}
|
||||
end
|
||||
def handle(tuple, _, state) do
|
||||
Logger.error("Unhandled but known packet #{elem(tuple, 0)}")
|
||||
{:unhandled, state}
|
||||
end
|
||||
end
|
@ -1,97 +0,0 @@
|
||||
# Amethyst - An experimental Minecraft server written in Elixir.
|
||||
# Copyright (C) 2024 KodiCraft
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
defmodule Amethyst.Server.Play do
|
||||
@moduledoc """
|
||||
This module contains the logic for the Play stage of the server.
|
||||
"""
|
||||
require Logger
|
||||
use Amethyst.Server
|
||||
|
||||
alias Amethyst.Minecraft.Read
|
||||
alias Amethyst.Minecraft.Write
|
||||
|
||||
@impl true
|
||||
def init(state) do
|
||||
state
|
||||
end
|
||||
|
||||
## DESERIALIZATION
|
||||
@impl true
|
||||
def deserialize(0x12, data) do
|
||||
{[channel], data} = Read.start(data) |> Read.string |> Read.stop
|
||||
{:serverbound_plugin_message, channel, data}
|
||||
end
|
||||
def deserialize(type, _) do
|
||||
raise RuntimeError, "Got unknown packet type #{type}!"
|
||||
end
|
||||
|
||||
## SERIALIZATION
|
||||
@impl true
|
||||
# Login https://wiki.vg/Protocol#Login_(play)
|
||||
def serialize({:login, eid, hardcore, dimensions,
|
||||
max_players, view_distance, simulation_distance,
|
||||
reduce_debug, enable_respawn_screen, limited_crafting,
|
||||
dim_type, dim_name, hashed_seed, gamemode, prev_gm,
|
||||
is_debug, is_flat, death_loc, portal_cooldown, enforce_chat}) when byte_size(hashed_seed) == 8 do
|
||||
# TODO: This is a big unreadable slab of serialization which makes bugs really hard to catch, it needs a proper rework at some point
|
||||
Write.varint(0x2B) <>
|
||||
Write.int(eid) <>
|
||||
Write.bool(hardcore) <>
|
||||
Write.varint(length(dimensions)) <>
|
||||
Write.list(dimensions, &Write.string/1) <>
|
||||
Write.varint(max_players) <>
|
||||
Write.varint(view_distance) <>
|
||||
Write.varint(simulation_distance) <>
|
||||
Write.bool(reduce_debug) <>
|
||||
Write.bool(enable_respawn_screen) <>
|
||||
Write.bool(limited_crafting) <>
|
||||
Write.varint(dim_type) <>
|
||||
Write.string(dim_name) <>
|
||||
hashed_seed <>
|
||||
Write.ubyte(gamemode_id(gamemode)) <>
|
||||
Write.byte(gamemode_id(prev_gm)) <>
|
||||
Write.bool(is_debug) <>
|
||||
Write.bool(is_flat) <>
|
||||
if(death_loc == nil, do: Write.bool(false), else: Write.bool(true) <> Write.string(elem(death_loc, 0)) <> Write.position(elem(death_loc, 1))) <>
|
||||
Write.varint(portal_cooldown) <>
|
||||
Write.bool(enforce_chat)
|
||||
end
|
||||
def serialize(packet) do
|
||||
raise ArgumentError, "Tried serializing unknown packet #{inspect(packet)}"
|
||||
end
|
||||
|
||||
## HANDLING
|
||||
@impl true
|
||||
def handle({:serverbound_plugin_message, channel, data}, _client, state) do
|
||||
Logger.debug("Got plugin message #{channel} with data #{inspect(data)}")
|
||||
{:ok, state}
|
||||
end
|
||||
def handle(tuple, _, state) do
|
||||
Logger.error("Unhandled but known packet #{elem(tuple, 0)}")
|
||||
{:unhandled, state}
|
||||
end
|
||||
|
||||
defp gamemode_id(gm) do
|
||||
case gm do
|
||||
nil -> -1
|
||||
:survival -> 0
|
||||
:creative -> 1
|
||||
:adventure -> 2
|
||||
:spectator -> 3
|
||||
end
|
||||
end
|
||||
end
|
@ -1,85 +0,0 @@
|
||||
# Amethyst - An experimental Minecraft server written in Elixir.
|
||||
# Copyright (C) 2024 KodiCraft
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
defmodule Amethyst.Server.Status do
|
||||
@moduledoc """
|
||||
This module contains the logic for the Status stage of the server.
|
||||
"""
|
||||
require Logger
|
||||
use Amethyst.Server
|
||||
|
||||
alias Amethyst.Minecraft.Read
|
||||
alias Amethyst.Minecraft.Write
|
||||
|
||||
@impl true
|
||||
def init(state) do
|
||||
state
|
||||
end
|
||||
|
||||
## DESERIALIZATION
|
||||
@impl true
|
||||
# Status Request https://wiki.vg/Protocol#Status_Request
|
||||
def deserialize(0x00, _) do
|
||||
{:status_request}
|
||||
end
|
||||
# Ping Request https://wiki.vg/Protocol#Ping_Request
|
||||
def deserialize(0x01, <<data::binary>>) do
|
||||
{[payload], ""} = Read.start(data) |> Read.long() |> Read.stop()
|
||||
{:ping_request, payload}
|
||||
end
|
||||
def deserialize(type, _) do
|
||||
raise RuntimeError, "Got unknown packet type #{type}!"
|
||||
end
|
||||
|
||||
## SERIALIZATION
|
||||
@impl true
|
||||
# Status Response https://wiki.vg/Protocol#Status_Response
|
||||
def serialize({:status_response, data}) do
|
||||
Write.varint(0x00) <> Write.string(data)
|
||||
end
|
||||
def serialize({:ping_response, payload}) do
|
||||
Write.varint(0x01) <> Write.long(payload)
|
||||
end
|
||||
def serialize(packet) do
|
||||
raise ArgumentError, "Tried serializing unknown packet #{inspect(packet)}"
|
||||
end
|
||||
|
||||
## HANDLING
|
||||
@impl true
|
||||
# Status Request https://wiki.vg/Protocol#Status_Request
|
||||
def handle({:status_request}, client, state) do
|
||||
# We want to make this more dynamic in the future, but this works for now
|
||||
packet = {:status_response, ~s({
|
||||
"version": {"name": "1.21", "protocol": 767},
|
||||
"players": {"max": -1, "online": 69, "sample": [{"name": "§dAmethyst§r", "id": "4566e69f-c907-48ee-8d71-d7ba5aa00d20"}]},
|
||||
"description": {"text":"Amethyst is an experimental server written in Elixir"},
|
||||
"enforcesSecureChat": false,
|
||||
"previewsChat": false,
|
||||
"preventsChatReports": true
|
||||
})}
|
||||
transmit(packet, client)
|
||||
{:ok, state}
|
||||
end
|
||||
def handle({:ping_request, payload}, client, state) do
|
||||
packet = {:ping_response, payload}
|
||||
transmit(packet, client)
|
||||
{:ok, state}
|
||||
end
|
||||
def handle(tuple, _, state) do
|
||||
Logger.error("Unhandled but known packet #{elem(tuple, 0)}")
|
||||
{:unhandled, state}
|
||||
end
|
||||
end
|
326
apps/amethyst/lib/states/configuration.ex
Normal file
326
apps/amethyst/lib/states/configuration.ex
Normal file
@ -0,0 +1,326 @@
|
||||
# Amethyst - An experimental Minecraft server written in Elixir.
|
||||
# Copyright (C) 2024 KodiCraft
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
defmodule Amethyst.ConnectionState.Configuration do
|
||||
require Amethyst.ConnectionState.Macros
|
||||
alias Amethyst.ConnectionState.Macros
|
||||
|
||||
require Logger
|
||||
|
||||
@moduledoc """
|
||||
This module contains the packets and logic for the Configuration state.
|
||||
"""
|
||||
Macros.defpacket_clientbound :cookie_request, 0x00, 767, [identifier: :string]
|
||||
Macros.defpacket_clientbound :clientbound_plugin_message, 0x01, 767, [channel: :string, data: :raw]
|
||||
Macros.defpacket_clientbound :disconnect, 0x02, 767, [reason: :nbt]
|
||||
Macros.defpacket_clientbound :finish_configuration, 0x03, 767, []
|
||||
Macros.defpacket_clientbound :clientbound_keep_alive, 0x04, 767, [id: :long]
|
||||
Macros.defpacket_clientbound :ping, 0x05, 767, [id: :int]
|
||||
Macros.defpacket_clientbound :reset_chat, 0x06, 767, []
|
||||
Macros.defpacket_clientbound :registry_data, 0x07, 767, [
|
||||
id: :string,
|
||||
entries: {:array, [
|
||||
id: :string,
|
||||
data: {:optional, :nbt}
|
||||
]}
|
||||
]
|
||||
Macros.defpacket_clientbound :remove_resource_pack, 0x08, 767, [
|
||||
uuid: {:optional, :uuid}
|
||||
]
|
||||
Macros.defpacket_clientbound :add_resource_pack, 0x09, 767, [
|
||||
uuid: :uuid,
|
||||
url: :string,
|
||||
hash: :string,
|
||||
forced: :bool,
|
||||
prompt_message: {:optional, :string}
|
||||
]
|
||||
Macros.defpacket_clientbound :store_cookie, 0x0A, 767, [identifier: :string, payload: :byte_array]
|
||||
Macros.defpacket_clientbound :transfer, 0x0B, 767, [host: :string, port: :varint]
|
||||
Macros.defpacket_clientbound :feature_flags, 0x0C, 767, [flags: {:array, [flag: :string]}]
|
||||
Macros.defpacket_clientbound :update_tags, 0x0D, 767, [
|
||||
tags: {:array, [
|
||||
registry: :string,
|
||||
tags: {:array, [
|
||||
name: :string,
|
||||
entries: {:array, [id: :varint]}
|
||||
]}
|
||||
]}
|
||||
]
|
||||
Macros.defpacket_clientbound :clientbound_known_packs, 0x0E, 767, [
|
||||
packs: {:array, [
|
||||
namespace: :string,
|
||||
id: :string,
|
||||
version: :string
|
||||
]}
|
||||
]
|
||||
Macros.defpacket_clientbound :custom_report_details, 0x0F, 767, [
|
||||
details: {:array, [
|
||||
title: :string,
|
||||
desctioption: :string
|
||||
]}
|
||||
]
|
||||
Macros.defpacket_clientbound :server_links, 0x10, 767, [
|
||||
links: {:array, [
|
||||
is_builtin: :bool,
|
||||
label: :string,
|
||||
url: :string
|
||||
]}
|
||||
]
|
||||
|
||||
Macros.defpacket_serverbound :client_information, 0x00, 767, [
|
||||
locale: :string,
|
||||
view_distance: :byte,
|
||||
chat_mode: :varint,
|
||||
chat_colors: :bool,
|
||||
displayed_skin_parts: :byte,
|
||||
main_hand: :varint,
|
||||
text_filtering: :bool,
|
||||
allow_server_listings: :bool
|
||||
]
|
||||
Macros.defpacket_serverbound :cookie_response, 0x01, 767, [
|
||||
key: :string,
|
||||
payload: {:optional, :byte_array}
|
||||
]
|
||||
Macros.defpacket_serverbound :serverbound_plugin_message, 0x02, 767, [channel: :string, data: :raw]
|
||||
Macros.defpacket_serverbound :acknowledge_finish_configuration, 0x03, 767, []
|
||||
Macros.defpacket_serverbound :serverbound_keep_alive, 0x04, 767, [id: :long]
|
||||
Macros.defpacket_serverbound :pong, 0x05, 767, [id: :int]
|
||||
Macros.defpacket_serverbound :resource_pack_response, 0x06, 767, [uuid: :uuid, result: :varint]
|
||||
Macros.defpacket_serverbound :serverbound_known_packs, 0x07, 767, [
|
||||
packs: {:array, [
|
||||
namespace: :string,
|
||||
id: :string,
|
||||
version: :string
|
||||
]}
|
||||
]
|
||||
|
||||
def handle(%{packet_type: :serverbound_plugin_message, channel: "minecraft:brand", data: data}, 767, state) do
|
||||
{[string], ""} = Amethyst.Minecraft.Read.start(data) |> Amethyst.Minecraft.Read.string() |> Amethyst.Minecraft.Read.stop()
|
||||
Logger.debug("Received brand: #{string}")
|
||||
send(self(), {:send_packet, %{
|
||||
packet_type: :clientbound_plugin_message,
|
||||
channel: "minecraft:brand",
|
||||
data: Amethyst.Minecraft.Write.string("Amethyst")
|
||||
}})
|
||||
state |> Map.put(:brand, string)
|
||||
end
|
||||
|
||||
def handle(%{
|
||||
packet_type: :client_information,
|
||||
locale: locale,
|
||||
view_distance: view_distance,
|
||||
chat_mode: chat_mode,
|
||||
chat_colors: chat_colors,
|
||||
displayed_skin_parts: displayed_skin_parts,
|
||||
main_hand: main_hand,
|
||||
text_filtering: text_filtering,
|
||||
allow_server_listings: allow_server_listings
|
||||
}, 767, state) do
|
||||
Logger.debug("Received client information")
|
||||
send(self(), {:send_packet, %{
|
||||
packet_type: :feature_flags,
|
||||
flags: []
|
||||
}})
|
||||
send(self(), {:send_packet, %{
|
||||
packet_type: :clientbound_known_packs,
|
||||
packs: [%{namespace: "minecraft", id: "base", version: "1.21"}]
|
||||
}})
|
||||
state
|
||||
|> Map.put(:locale, locale)
|
||||
|> Map.put(:view_distance, view_distance)
|
||||
|> Map.put(:chat_mode, chat_mode)
|
||||
|> Map.put(:chat_colors, chat_colors)
|
||||
|> Map.put(:displayed_skin_parts, displayed_skin_parts)
|
||||
|> Map.put(:main_hand, main_hand)
|
||||
|> Map.put(:text_filtering, text_filtering)
|
||||
|> Map.put(:allow_server_listings, allow_server_listings)
|
||||
end
|
||||
|
||||
def handle(%{packet_type: :serverbound_known_packs, packs: _packs}, 767, _state) do
|
||||
Logger.debug("Received known packs")
|
||||
import Amethyst.NBT.Write
|
||||
# TODO: Of course, the registries shouldn't be hard-coded
|
||||
send(self(), {:send_packet, %{
|
||||
packet_type: :registry_data,
|
||||
id: "minecraft:dimension_type",
|
||||
entries: [
|
||||
%{id: "amethyst:basic", data: compound(%{
|
||||
"has_skylight" => byte(1),
|
||||
"has_ceiling" => byte(0),
|
||||
"ultrawarm" => byte(0),
|
||||
"natural" => byte(1),
|
||||
"coordinate_scale" => float(1.0),
|
||||
"bed_works" => byte(1),
|
||||
"respawn_anchor_works" => byte(1),
|
||||
"min_y" => int(0),
|
||||
"height" => int(256),
|
||||
"logical_height" => int(256),
|
||||
"infiniburn" => string("#"),
|
||||
"effects" => string("minecraft:overworld"),
|
||||
"ambient_light" => float(0.0),
|
||||
"piglin_safe" => byte(0),
|
||||
"has_raids" => byte(1),
|
||||
"monster_spawn_light_level" => int(0),
|
||||
"monster_spawn_block_light_limit" => int(0)
|
||||
})}
|
||||
]
|
||||
}})
|
||||
send(self(), {:send_packet, %{
|
||||
packet_type: :registry_data,
|
||||
id: "minecraft:painting_variant", entries: [
|
||||
%{id: "minecraft:kebab", data: compound(%{
|
||||
"asset_id" => string("minecraft:kebab"),
|
||||
"height" => int(1),
|
||||
"width" => int(1),
|
||||
})}
|
||||
]
|
||||
}})
|
||||
send(self(), {:send_packet, %{
|
||||
packet_type: :registry_data,
|
||||
id: "minecraft:wolf_variant",
|
||||
entries: [
|
||||
%{id: "minecraft:wolf_ashen", data: compound(%{
|
||||
"wild_texture" => string("minecraft:entity/wolf/wolf_ashen"),
|
||||
"tame_texture" => string("minecraft:entity/wolf/wolf_ashen_tame"),
|
||||
"angry_texture" => string("minecraft:entity/wolf/wolf_ashen_angry"),
|
||||
"biomes" => string("amethyst:basic"),
|
||||
})}
|
||||
]
|
||||
}})
|
||||
# https://gist.github.com/WinX64/ab8c7a8df797c273b32d3a3b66522906 minecraft:plains
|
||||
basic_biome = compound(%{
|
||||
"effects" => compound(%{
|
||||
"sky_color" => int(7907327),
|
||||
"water_fog_color" => int(329011),
|
||||
"fog_color" => int(12638463),
|
||||
"water_color" => int(4159204),
|
||||
"mood_sound" => compound(%{
|
||||
"tick_delay" => int(6000),
|
||||
"offset" => float(2.0),
|
||||
"sound" => string("minecraft:ambient.cave"),
|
||||
"block_search_extent" => int(8)
|
||||
}),
|
||||
}),
|
||||
"has_precipitation" => byte(1),
|
||||
"temperature" => float(0.8),
|
||||
"downfall" => float(0.4),
|
||||
})
|
||||
send(self(), {:send_packet, %{
|
||||
packet_type: :registry_data,
|
||||
id: "minecraft:worldgen/biome",
|
||||
entries: [
|
||||
%{id: "amethyst:basic", data: basic_biome},
|
||||
%{id: "minecraft:plains", data: basic_biome}
|
||||
]
|
||||
}})
|
||||
# this game sucks
|
||||
generic_damage = compound(%{
|
||||
"scaling" => string("when_caused_by_living_non_player"),
|
||||
"exhaustion" => float(0.0),
|
||||
"message_id" => string("generic")
|
||||
})
|
||||
send(self(), {:send_packet, %{
|
||||
packet_type: :registry_data,
|
||||
id: "minecraft:damage_type",
|
||||
entries: [
|
||||
%{id: "minecraft:in_fire", data: generic_damage},
|
||||
%{id: "minecraft:campfire", data: generic_damage},
|
||||
%{id: "minecraft:lightning_bolt", data: generic_damage},
|
||||
%{id: "minecraft:on_fire", data: generic_damage},
|
||||
%{id: "minecraft:lava", data: generic_damage},
|
||||
%{id: "minecraft:cramming", data: generic_damage},
|
||||
%{id: "minecraft:drown", data: generic_damage},
|
||||
%{id: "minecraft:starve", data: generic_damage},
|
||||
%{id: "minecraft:cactus", data: generic_damage},
|
||||
%{id: "minecraft:fall", data: generic_damage},
|
||||
%{id: "minecraft:fly_into_wall", data: generic_damage},
|
||||
%{id: "minecraft:out_of_world", data: generic_damage},
|
||||
%{id: "minecraft:generic", data: generic_damage},
|
||||
%{id: "minecraft:magic", data: generic_damage},
|
||||
%{id: "minecraft:wither", data: generic_damage},
|
||||
%{id: "minecraft:dragon_breath", data: generic_damage},
|
||||
%{id: "minecraft:dry_out", data: generic_damage},
|
||||
%{id: "minecraft:sweet_berry_bush", data: generic_damage},
|
||||
%{id: "minecraft:freeze", data: generic_damage},
|
||||
%{id: "minecraft:stalagmite", data: generic_damage},
|
||||
%{id: "minecraft:outside_border", data: generic_damage},
|
||||
%{id: "minecraft:generic_kill", data: generic_damage},
|
||||
%{id: "minecraft:hot_floor", data: generic_damage},
|
||||
%{id: "minecraft:in_wall", data: generic_damage},
|
||||
]
|
||||
}})
|
||||
send(self(), {:send_packet, %{packet_type: :finish_configuration}})
|
||||
:ok
|
||||
end
|
||||
|
||||
def handle(%{packet_type: :acknowledge_finish_configuration}, 767, state) do
|
||||
Logger.debug("Received acknowledge finish configuration")
|
||||
send(self(), {:set_state, Amethyst.ConnectionState.Play})
|
||||
|
||||
game = Application.fetch_env!(:amethyst, :default_game) |> Amethyst.GameCoordinator.find_or_create()
|
||||
state = state |> Map.put(:game, game)
|
||||
login = Amethyst.Game.login(game, state)
|
||||
case login do
|
||||
:reject ->
|
||||
send(self(), {:disconnect, "Default game rejected connection"})
|
||||
:ok
|
||||
{:accept, {x, y, z}, {yaw, pitch}} ->
|
||||
send(self(), {:send_packet, %{
|
||||
packet_type: :login,
|
||||
entity_id: 0,
|
||||
is_hardcore: false,
|
||||
dimensions: [%{name: "minecraft:overworld"}],
|
||||
max_players: 0,
|
||||
view_distance: 16,
|
||||
simulation_distance: 16,
|
||||
reduced_debug_info: false,
|
||||
enable_respawn_screen: true,
|
||||
do_limited_crafting: false,
|
||||
dimension_type: 0,
|
||||
dimension_name: "minecraft:overworld",
|
||||
hashed_seed: 0,
|
||||
game_mode: 1,
|
||||
previous_game_mode: -1,
|
||||
is_debug: false,
|
||||
is_flat: false,
|
||||
death_location: nil,
|
||||
portal_cooldown: 0,
|
||||
enforces_secure_chat: false
|
||||
}})
|
||||
send(self(), {:send_packet, %{
|
||||
packet_type: :synchronize_player_position,
|
||||
x: x, y: y, z: z, yaw: yaw, pitch: pitch, teleport_id: 0, flags: 0x00
|
||||
}})
|
||||
send(self(), {:send_packet, Amethyst.ConnectionState.Play.ge_start_waiting_for_level_chunks(767)})
|
||||
send(self(), {:send_packet, %{packet_type: :set_center_chunk,
|
||||
chunk_x: div(floor(x), 16),
|
||||
chunk_z: div(floor(z), 16)
|
||||
}})
|
||||
send(self(), {:set_position, {x, y, z}})
|
||||
# Begin keepalive loop
|
||||
# TODO: Put it under some supervisor
|
||||
me = self()
|
||||
pid = spawn(fn -> Amethyst.ConnectionState.Play.keepalive_loop(me) end)
|
||||
state |> Map.put(:keepalive, pid)
|
||||
end
|
||||
end
|
||||
|
||||
def disconnect(reason) do
|
||||
%{packet_type: :disconnect, reason: {:compound, %{
|
||||
"text" => {:string, reason}
|
||||
}}}
|
||||
end
|
||||
end
|
56
apps/amethyst/lib/states/handhsake.ex
Normal file
56
apps/amethyst/lib/states/handhsake.ex
Normal file
@ -0,0 +1,56 @@
|
||||
# Amethyst - An experimental Minecraft server written in Elixir.
|
||||
# Copyright (C) 2024 KodiCraft
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
defmodule Amethyst.ConnectionState.Handshake do
|
||||
require Amethyst.ConnectionState.Macros
|
||||
alias Amethyst.ConnectionState.Macros
|
||||
|
||||
require Logger
|
||||
|
||||
@moduledoc """
|
||||
This module contains the packets and logic for the Handshake state.
|
||||
"""
|
||||
# Notice that in the Handshake state, our version is always 0.
|
||||
Macros.defpacket_serverbound :handshake, 0x00, 0, [
|
||||
version: :varint,
|
||||
address: :string,
|
||||
port: :ushort,
|
||||
next: :varint
|
||||
]
|
||||
|
||||
def handle(%{packet_type: :handshake, version: 767, address: address, port: port, next: next}, 0, _state) do
|
||||
Logger.debug("Received handshake for #{address}:#{port} with version 767")
|
||||
case next do
|
||||
1 ->
|
||||
send(self(), {:set_state, Amethyst.ConnectionState.Status})
|
||||
send(self(), {:set_version, 767})
|
||||
:ok
|
||||
2 ->
|
||||
send(self(), {:set_state, Amethyst.ConnectionState.Login})
|
||||
send(self(), {:set_version, 767})
|
||||
:ok
|
||||
3 ->
|
||||
send(self(), {:set_state, Amethyst.ConnectionState.Transfer})
|
||||
send(self(), {:set_version, 767})
|
||||
:ok
|
||||
_ -> {:error, "Invalid next state"}
|
||||
end
|
||||
end
|
||||
|
||||
def disconnect(_reason) do
|
||||
# When disconnecting from the handshake state, we can't send any sort of reason.
|
||||
end
|
||||
end
|
92
apps/amethyst/lib/states/login.ex
Normal file
92
apps/amethyst/lib/states/login.ex
Normal file
@ -0,0 +1,92 @@
|
||||
# Amethyst - An experimental Minecraft server written in Elixir.
|
||||
# Copyright (C) 2024 KodiCraft
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
defmodule Amethyst.ConnectionState.Login do
|
||||
require Amethyst.ConnectionState.Macros
|
||||
alias Amethyst.ConnectionState.Macros
|
||||
|
||||
require Logger
|
||||
|
||||
@moduledoc """
|
||||
This module contains the packets and logic for the Login state.
|
||||
"""
|
||||
Macros.defpacket_clientbound :disconnect, 0x00, 767, [reason: :json]
|
||||
Macros.defpacket_clientbound :encryption_request, 0x01, 767, [
|
||||
server_id: :string,
|
||||
public_key: :byte_array,
|
||||
verify_token: :byte_array,
|
||||
should_authenticate: :bool
|
||||
]
|
||||
Macros.defpacket_clientbound :login_success, 0x02, 767, [
|
||||
uuid: :uuid,
|
||||
username: :string,
|
||||
properties: {:array, [
|
||||
name: :string,
|
||||
value: :string,
|
||||
signature: {:optional, :string}
|
||||
]},
|
||||
strict_error_handling: :bool
|
||||
]
|
||||
Macros.defpacket_clientbound :set_compression, 0x03, 767, [threshold: :varint]
|
||||
Macros.defpacket_clientbound :login_plugin_request, 0x04, 767, [
|
||||
message_id: :varint,
|
||||
channel: :string,
|
||||
data: :raw
|
||||
]
|
||||
Macros.defpacket_clientbound :cookie_request, 0x05, 767, [identifier: :string]
|
||||
|
||||
Macros.defpacket_serverbound :login_start, 0x00, 767, [name: :string, player_uuid: :uuid]
|
||||
Macros.defpacket_serverbound :encryption_response, 0x01, 767, [
|
||||
shared_secret: :byte_array,
|
||||
verify_token: :byte_array
|
||||
]
|
||||
Macros.defpacket_serverbound :login_plugin_response, 0x02, 767, [
|
||||
message_id: :varint,
|
||||
data: {:optional, :raw}
|
||||
]
|
||||
Macros.defpacket_serverbound :login_acknowledged, 0x03, 767, []
|
||||
Macros.defpacket_serverbound :cookie_response, 0x04, 767, [identifier: :string, payload: {:optional, :byte_array}]
|
||||
|
||||
def handle(%{packet_type: :login_start, name: name, player_uuid: player_uuid}, 767, _state) do
|
||||
Logger.debug("Received login start for #{name} with UUID #{player_uuid}")
|
||||
if Application.fetch_env!(:amethyst, :encryption) do
|
||||
raise RuntimeError, "Encryption is not currently supported"
|
||||
else
|
||||
send(self(), {:send_packet, %{
|
||||
packet_type: :login_success,
|
||||
uuid: player_uuid,
|
||||
username: name,
|
||||
properties: [],
|
||||
strict_error_handling: true
|
||||
}})
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
def handle(%{packet_type: :login_acknowledged}, 767, _state) do
|
||||
Logger.debug("Received login acknowledged")
|
||||
send(self(), {:set_state, Amethyst.ConnectionState.Configuration})
|
||||
:ok
|
||||
end
|
||||
|
||||
def disconnect(reason) do
|
||||
%{packet_type: :disconnect, reason:
|
||||
%{
|
||||
"text" => reason
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
160
apps/amethyst/lib/states/macros.ex
Normal file
160
apps/amethyst/lib/states/macros.ex
Normal file
@ -0,0 +1,160 @@
|
||||
# Amethyst - An experimental Minecraft server written in Elixir.
|
||||
# Copyright (C) 2024 KodiCraft
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
defmodule Amethyst.ConnectionState.Macros do
|
||||
require Logger
|
||||
defmacro defpacket_serverbound(name, id, version, signature, where \\ true) do
|
||||
quote do
|
||||
def deserialize(unquote(id), unquote(version), data) when unquote(where) do
|
||||
{packet, ""} = Amethyst.ConnectionState.Macros.read_signature(data, unquote(signature))
|
||||
packet |> Map.put(:packet_type, unquote(name))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defmacro defpacket_clientbound(name, id, version, signature, where \\ true) do
|
||||
quote do
|
||||
def serialize(%{packet_type: unquote(name)} = packet, unquote(version)) when unquote(where) do
|
||||
if Amethyst.ConnectionState.Macros.check_type(packet, unquote(signature)) do
|
||||
Amethyst.Minecraft.Write.varint(unquote(id)) <> Amethyst.ConnectionState.Macros.write_signature(packet, unquote(signature))
|
||||
else
|
||||
raise "Invalid packet type for #{unquote(name)}! Got #{inspect(packet)}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
alias Amethyst.Minecraft.Read
|
||||
alias Amethyst.Minecraft.Write
|
||||
|
||||
def read_signature(data, signature) do
|
||||
names = Enum.map(signature, fn {name, _type} -> name end)
|
||||
{got, rest} = Enum.reduce(signature, Read.start(data), fn {_name, type}, {acc, rest, :reversed} ->
|
||||
case type do
|
||||
{:optional, {:compound, signature}} ->
|
||||
{[exists], rest} = Read.start(rest) |> Read.bool() |> Read.stop()
|
||||
if exists do
|
||||
{item, rest} = read_signature(rest, signature)
|
||||
{[item | acc], rest, :reversed}
|
||||
else
|
||||
{[nil | acc], rest, :reversed}
|
||||
end
|
||||
{:optional, t} ->
|
||||
{[exists], rest} = Read.start(rest) |> Read.bool() |> Read.stop()
|
||||
if exists do
|
||||
apply(Read, t, [{acc, rest, :reversed}])
|
||||
else
|
||||
{[nil | acc], rest, :reversed}
|
||||
end
|
||||
{:array, signature} ->
|
||||
{[count], rest} = Read.start(rest) |> Read.varint() |> Read.stop()
|
||||
if count == 0 do
|
||||
{[[] | acc], rest, :reversed}
|
||||
else
|
||||
{items, rest} = Enum.reduce(1..count, {[], rest}, fn _, {acc, rest} ->
|
||||
{item, rest} = read_signature(rest, signature)
|
||||
{[item | acc], rest}
|
||||
end)
|
||||
{[Enum.reverse(items) | acc], rest, :reversed}
|
||||
end
|
||||
t -> apply(Read, t, [{acc, rest, :reversed}])
|
||||
end
|
||||
end) |> Read.stop()
|
||||
|
||||
{Enum.zip(names, got) |> Map.new(), rest}
|
||||
end
|
||||
|
||||
def write_signature(packet, signature) do
|
||||
Enum.reduce(signature, "", fn {name, type}, acc ->
|
||||
case type do
|
||||
{:optional, {:compound, signature}} ->
|
||||
case Map.get(packet, name) do
|
||||
nil -> acc <> Write.bool(false)
|
||||
_ -> acc <> Write.bool(true) <> write_signature(Map.get(packet, name), signature)
|
||||
end
|
||||
{:optional, t} ->
|
||||
case Map.get(packet, name) do
|
||||
nil -> acc <> Write.bool(false)
|
||||
_ -> acc <> Write.bool(true) <> apply(Write, t, [Map.get(packet, name)])
|
||||
end
|
||||
{:array, signature} ->
|
||||
acc <> Write.varint(Enum.count(Map.get(packet, name))) <>
|
||||
Enum.reduce(Map.get(packet, name), "", fn item, acc ->
|
||||
acc <> write_signature(item, signature)
|
||||
end)
|
||||
{:literal, {type, value}} -> acc <> apply(Write, type, [value])
|
||||
t -> acc <> apply(Write, t, [Map.get(packet, name)])
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def check_type(packet, signature) do
|
||||
try do
|
||||
Enum.all?(signature, fn {name, type} ->
|
||||
case Map.get(packet, name, :missing) do
|
||||
:missing ->
|
||||
if elem(type, 0) == :literal do
|
||||
true
|
||||
else
|
||||
throw {:missing, name}
|
||||
end
|
||||
value -> case type_matches(value, type) do
|
||||
true -> true
|
||||
false -> throw {:mismatch, name, value, type}
|
||||
end
|
||||
end
|
||||
end)
|
||||
catch
|
||||
reason ->
|
||||
Logger.debug("Found invalid packet type: #{inspect(reason)}")
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def type_matches(value, :bool) when is_boolean(value), do: true
|
||||
def type_matches(value, :byte) when is_integer(value) and value in -128..127, do: true
|
||||
def type_matches(value, :ubyte) when is_integer(value) and value in 0..255, do: true
|
||||
def type_matches(value, :short) when is_integer(value) and value in -32768..32767, do: true
|
||||
def type_matches(value, :ushort) when is_integer(value) and value in 0..65535, do: true
|
||||
def type_matches(value, :int) when is_integer(value) and value in -2147483648..2147483647, do: true
|
||||
def type_matches(value, :long) when is_integer(value) and value in -9223372036854775808..9223372036854775807, do: true
|
||||
def type_matches(value, :float) when is_number(value), do: true
|
||||
def type_matches(value, :double) when is_number(value), do: true
|
||||
def type_matches(value, :varint) when is_integer(value) and value in -2147483648..2147483647, do: true
|
||||
def type_matches(value, :varlong) when is_integer(value) and value in -9223372036854775808..9223372036854775807, do: true
|
||||
def type_matches(value, :uuid) when is_binary(value) and byte_size(value) == 36, do: true
|
||||
def type_matches(value, :string) when is_binary(value), do: true
|
||||
def type_matches(value, :raw) when is_binary(value), do: true
|
||||
def type_matches(value, :byte_array) when is_binary(value), do: true
|
||||
def type_matches({x, y, z}, :position) when
|
||||
is_integer(x) and x in -33554432..33554431 and
|
||||
is_integer(y) and y in -2048..2047 and
|
||||
is_integer(z) and z in -33554432..33554431, do: true
|
||||
def type_matches(value, :nbt), do: Amethyst.NBT.Write.check_type(value)
|
||||
def type_matches(value, :json) do
|
||||
case Jason.encode(value) do
|
||||
{:ok, _} -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
def type_matches(value, {:optional, _type}) when is_nil(value), do: true
|
||||
def type_matches(value, {:optional, type}), do: type_matches(value, type)
|
||||
def type_matches(value, {:array, signature}) when is_list(value), do: Enum.all?(value, fn item -> check_type(item, signature) end)
|
||||
def type_matches(value, {:compound, signature}) when is_map(value), do: check_type(value, signature)
|
||||
def type_matches(_, _) do
|
||||
false
|
||||
end
|
||||
end
|
265
apps/amethyst/lib/states/play.ex
Normal file
265
apps/amethyst/lib/states/play.ex
Normal file
@ -0,0 +1,265 @@
|
||||
# Amethyst - An experimental Minecraft server written in Elixir.
|
||||
# Copyright (C) 2024 KodiCraft
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
defmodule Amethyst.ConnectionState.Play do
|
||||
require Amethyst.ConnectionState.Macros
|
||||
alias Amethyst.ConnectionState.Macros
|
||||
|
||||
require Logger
|
||||
|
||||
@moduledoc """
|
||||
This module contains the packets and logic for the Play state.
|
||||
"""
|
||||
Macros.defpacket_clientbound :disconnect, 0x1D, 767, [reason: :nbt]
|
||||
Macros.defpacket_clientbound :keep_alive, 0x26, 767, [id: :long]
|
||||
Macros.defpacket_clientbound :chunk_data_and_update_light, 0x27, 767, [
|
||||
chunk_x: :int,
|
||||
chunk_z: :int,
|
||||
heightmaps: :nbt,
|
||||
data: :byte_array,
|
||||
block_entities: {:array, [
|
||||
packed_xz: :ubyte, # TODO: This would be interesting to have in a clearer format
|
||||
y: :short,
|
||||
type: :varint,
|
||||
data: :nbt
|
||||
]},
|
||||
sky_light_mask: :raw,
|
||||
block_light_mask: :raw,
|
||||
empty_sky_light_mask: :raw,
|
||||
empty_block_light_mask: :raw,
|
||||
sky_light_arrays: {:array, [
|
||||
sky_light_array: :byte_array
|
||||
]},
|
||||
block_light_arrays: {:array, [
|
||||
block_light_array: :byte_array
|
||||
]}
|
||||
]
|
||||
Macros.defpacket_clientbound :login, 0x2B, 767, [
|
||||
entity_id: :int,
|
||||
is_hardcore: :bool,
|
||||
dimensions: {:array, [name: :string]},
|
||||
max_players: :varint,
|
||||
view_distance: :varint,
|
||||
simulation_distance: :varint,
|
||||
reduced_debug_info: :bool,
|
||||
enable_respawn_screen: :bool,
|
||||
do_limited_crafting: :bool,
|
||||
dimension_type: :varint,
|
||||
dimension_name: :string,
|
||||
hashed_seed: :long,
|
||||
game_mode: :ubyte,
|
||||
previous_game_mode: :byte,
|
||||
is_debug: :bool,
|
||||
is_flat: :bool,
|
||||
death_location: {:optional, {:compound, [
|
||||
dimension: :string,
|
||||
location: :pos
|
||||
]}},
|
||||
portal_cooldown: :varint,
|
||||
enforces_secure_chat: :bool,
|
||||
]
|
||||
Macros.defpacket_clientbound :player_info_update_add_player, 0x2E, 767, [
|
||||
actions: {:literal, 0x01},
|
||||
players: {:array, [
|
||||
uuid: :uuid,
|
||||
name: :string,
|
||||
properties: {:array, [
|
||||
name: :string,
|
||||
value: :string,
|
||||
signature: {:optional, :string},
|
||||
]}
|
||||
]}
|
||||
]
|
||||
Macros.defpacket_clientbound :player_info_update_initialize_chat, 0x2E, 767, [
|
||||
actions: {:literal, 0x02},
|
||||
players: {:array, [
|
||||
uuid: :uuid,
|
||||
data: {:optional, {:compound, [
|
||||
chat_session_id: :uuid,
|
||||
public_key_expiry_time: :long,
|
||||
encoded_public_key: :byte_array,
|
||||
public_key_signature: :byte_array
|
||||
]}}
|
||||
]}
|
||||
]
|
||||
Macros.defpacket_clientbound :player_info_update_update_game_mode, 0x2E, 767, [
|
||||
actions: {:literal, 0x04},
|
||||
players: {:array, [
|
||||
uuid: :uuid,
|
||||
gamemode: :varint
|
||||
]}
|
||||
]
|
||||
Macros.defpacket_clientbound :player_info_update_update_listed, 0x2E, 767, [
|
||||
actions: {:literal, 0x08},
|
||||
players: {:array, [
|
||||
uuid: :uuid,
|
||||
listed: :bool
|
||||
]}
|
||||
]
|
||||
Macros.defpacket_clientbound :player_info_update_update_latency, 0x2E, 767, [
|
||||
actions: {:literal, 0x10},
|
||||
players: {:array, [
|
||||
uuid: :uuid,
|
||||
ping: :varint, # Milliseconds
|
||||
]}
|
||||
]
|
||||
Macros.defpacket_clientbound :player_info_update_update_display_name, 0x2E, 767, [
|
||||
actions: {:literal, 0x20},
|
||||
players: {:array, [
|
||||
uuid: :uuid,
|
||||
display_name: {:optional, :nbt}
|
||||
]}
|
||||
]
|
||||
Macros.defpacket_clientbound :synchronize_player_position, 0x40, 767, [
|
||||
x: :double,
|
||||
y: :double,
|
||||
z: :double,
|
||||
yaw: :float,
|
||||
pitch: :float,
|
||||
flags: :byte,
|
||||
teleport_id: :varint
|
||||
]
|
||||
Macros.defpacket_clientbound :set_center_chunk, 0x54, 767, [
|
||||
chunk_x: :varint, chunk_z: :varint
|
||||
]
|
||||
|
||||
Macros.defpacket_clientbound :game_event, 0x22, 767, [
|
||||
event: :ubyte, value: :float
|
||||
]
|
||||
# We can use functions to wrap over this packet and make it a bit clearer.
|
||||
# Taking the protocol version here makes it less portable but whatever, fuck this packet
|
||||
def ge_no_respawn_block_available(767), do: %{packet_type: :game_event, event: 0, value: 0}
|
||||
def ge_begin_raining(767), do: %{packet_type: :game_event, event: 1, value: 0}
|
||||
def ge_end_raining(767), do: %{packet_type: :game_event, event: 2, value: 0}
|
||||
def ge_change_game_mode(767, gm) when is_integer(gm), do: %{packet_type: :game_event, event: 3, value: gm}
|
||||
def ge_win_game(767, credits?) when is_integer(credits?), do: %{packet_type: :game_event, event: 4, value: credits?}
|
||||
def ge_game_event(767, event) when is_integer(event), do: %{packet_type: :game_event, event: 5, value: event}
|
||||
def ge_arrow_hit_player(767), do: %{packet_type: :game_event, event: 6, value: 0}
|
||||
def ge_rain_level_change(767, value) when is_number(value), do: %{packet_type: :game_event, event: 7, value: value}
|
||||
def ge_thunder_level_change(767, value) when is_number(value), do: %{packet_type: :game_event, event: 8, value: value}
|
||||
def ge_play_pufferfish_sting_sound(767), do: %{packet_type: :game_event, event: 9, value: 0}
|
||||
def ge_play_elder_guardian_mob_appearance(767), do: %{packet_type: :game_event, event: 10, value: 0}
|
||||
def ge_enable_respawn_screen(767, enabled?) when is_integer(enabled?), do: %{packet_type: :game_event, event: 11, value: enabled?}
|
||||
def ge_limited_crafting(767, enabled?) when is_integer(enabled?), do: %{packet_type: :game_event, event: 12, value: enabled?}
|
||||
def ge_start_waiting_for_level_chunks(767), do: %{packet_type: :game_event, event: 13, value: 0}
|
||||
|
||||
Macros.defpacket_serverbound :confirm_teleportation, 0x00, 767, [teleport_id: :varint]
|
||||
Macros.defpacket_serverbound :serverbound_plugin_message, 0x12, 767, [channel: :string, data: :raw]
|
||||
Macros.defpacket_serverbound :keep_alive, 0x18, 767, [id: :long]
|
||||
Macros.defpacket_serverbound :set_player_position, 0x1A, 767, [
|
||||
x: :double,
|
||||
feet_y: :double,
|
||||
z: :double,
|
||||
on_ground: :bool
|
||||
]
|
||||
Macros.defpacket_serverbound :set_player_position_and_rotation, 0x1B, 767, [
|
||||
x: :double,
|
||||
feet_y: :double,
|
||||
z: :double,
|
||||
yaw: :float,
|
||||
pitch: :float,
|
||||
on_ground: :bool
|
||||
]
|
||||
Macros.defpacket_serverbound :set_player_rotation, 0x1C, 767, [
|
||||
yaw: :float,
|
||||
pitch: :float,
|
||||
on_ground: :bool # I don't understand their obsession with this...
|
||||
]
|
||||
Macros.defpacket_serverbound :set_player_on_ground, 0x1D, 767, [on_ground: :bool]
|
||||
Macros.defpacket_serverbound :player_command, 0x25, 767, [eid: :varint, action_id: :varint, jump_boost: :varint]
|
||||
|
||||
def handle(%{packet_type: :confirm_teleportation, teleport_id: id}, 767, state) do
|
||||
Amethyst.Game.accept_teleport(state[:game], id)
|
||||
:ok
|
||||
end
|
||||
|
||||
def handle(%{packet_type: :set_player_position_and_rotation, x: x, feet_y: y, z: z, yaw: yaw, pitch: pitch, on_ground: _ground}, 767, state) do
|
||||
# I don't know why we would ever trust on_ground here... the server computes that
|
||||
Amethyst.Game.player_position(state[:game], {x, y, z})
|
||||
Amethyst.Game.player_rotation(state[:game], {yaw, pitch})
|
||||
:ok
|
||||
end
|
||||
def handle(%{packet_type: :serverbound_plugin_message, channel: channel, data: _}, 767, _state) do
|
||||
Logger.debug("Got plugin message on #{channel}")
|
||||
end
|
||||
def handle(%{packet_type: :set_player_position, x: x, feet_y: y, z: z, on_ground: _ground}, 767, state) do
|
||||
# I don't know why we would ever trust on_ground here... the server computes that
|
||||
Amethyst.Game.player_position(state[:game], {x, y, z})
|
||||
:ok
|
||||
end
|
||||
def handle(%{packet_type: :set_player_rotation, yaw: yaw, pitch: pitch, on_ground: _ground}, 767, state) do
|
||||
# I don't know why we would ever trust on_ground here... the server computes that
|
||||
Amethyst.Game.player_rotation(state[:game], {yaw, pitch})
|
||||
:ok
|
||||
end
|
||||
def handle(%{packet_type: :set_player_on_ground, on_ground: _}, 767, _state) do
|
||||
:ok # Again, don't trust the client for something we can compute
|
||||
end
|
||||
def handle(%{packet_type: :player_command, eid: _eid, action_id: aid, jump_boost: _horse_jump}, 767, _state) do
|
||||
# TODO: Actually handle these events
|
||||
case aid do
|
||||
0 -> # Start sneaking
|
||||
:ok
|
||||
1 -> # Stop sneaking
|
||||
:ok
|
||||
2 -> # Leave bed
|
||||
:ok
|
||||
3 -> # Start sprinting
|
||||
:ok
|
||||
4 -> # Stop sprinting
|
||||
:ok
|
||||
5 -> # Start horse jump
|
||||
:ok
|
||||
6 -> # Stop horse jump
|
||||
:ok
|
||||
7 -> # Open vehicle inventory
|
||||
:ok
|
||||
8 -> # Start elytra flying
|
||||
:ok
|
||||
_ -> raise RuntimeError, "Unknown Player Command Action ID"
|
||||
end
|
||||
end
|
||||
|
||||
def handle(%{packet_type: :keep_alive, id: id}, 767, state) do
|
||||
ka = state |> Map.get(:keepalive)
|
||||
send(ka, {:respond, id})
|
||||
:ok
|
||||
end
|
||||
# This function should be started on a new task under the connection handler
|
||||
# and is responsible for keepalive logic.
|
||||
def keepalive_loop(player) do
|
||||
Process.link(player) # Is it fine to do this on loop?
|
||||
<<id::32>> = :rand.bytes(4)
|
||||
send(player, {:send_packet, %{packet_type: :keep_alive, id: id}})
|
||||
receive do
|
||||
{:respond, ^id} ->
|
||||
:timer.sleep(250)
|
||||
keepalive_loop(player)
|
||||
after
|
||||
15_000 ->
|
||||
send(player, {:disconnect, "Timed out! Connection overloaded?"})
|
||||
end
|
||||
end
|
||||
|
||||
def disconnect(reason) do
|
||||
%{
|
||||
packet_type: :disconnect,
|
||||
reason: {:compound, %{
|
||||
"text" => {:string, reason}
|
||||
}}
|
||||
}
|
||||
end
|
||||
end
|
60
apps/amethyst/lib/states/status.ex
Normal file
60
apps/amethyst/lib/states/status.ex
Normal file
@ -0,0 +1,60 @@
|
||||
# Amethyst - An experimental Minecraft server written in Elixir.
|
||||
# Copyright (C) 2024 KodiCraft
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
defmodule Amethyst.ConnectionState.Status do
|
||||
require Amethyst.ConnectionState.Macros
|
||||
alias Amethyst.ConnectionState.Macros
|
||||
|
||||
require Logger
|
||||
|
||||
@moduledoc """
|
||||
This module contains the packets and logic for the Status state.
|
||||
"""
|
||||
Macros.defpacket_clientbound :status_response, 0x00, 767, [json_response: :string]
|
||||
Macros.defpacket_clientbound :pong_response, 0x01, 767, [payload: :long]
|
||||
|
||||
Macros.defpacket_serverbound :status_request, 0x00, 767, []
|
||||
Macros.defpacket_serverbound :ping_request, 0x01, 767, [payload: :long]
|
||||
|
||||
def handle(%{packet_type: :status_request}, 767, _state) do
|
||||
Logger.debug("Received status request")
|
||||
send(self(), {:send_packet, %{
|
||||
packet_type: :status_response,
|
||||
json_response: ~s({
|
||||
"version": {"name": "1.21", "protocol": 767},
|
||||
"players": {"max": -1, "online": 69, "sample": [{"name": "§dAmethyst§r", "id": "4566e69f-c907-48ee-8d71-d7ba5aa00d20"}]},
|
||||
"description": {"text":"Amethyst is an experimental server written in Elixir"},
|
||||
"enforcesSecureChat": false,
|
||||
"previewsChat": false,
|
||||
"preventsChatReports": true
|
||||
})
|
||||
}})
|
||||
:ok
|
||||
end
|
||||
|
||||
def handle(%{packet_type: :ping_request, payload: payload}, 767, _state) do
|
||||
Logger.debug("Received ping request")
|
||||
send(self(), {:send_packet, %{
|
||||
packet_type: :pong_response,
|
||||
payload: payload
|
||||
}})
|
||||
:ok
|
||||
end
|
||||
|
||||
def disconnect(_reason) do
|
||||
# When disconnecting from the status state, we can't send any sort of reason.
|
||||
end
|
||||
end
|
@ -20,7 +20,7 @@ defmodule Amethyst.MixProject do
|
||||
source_url: "https://git.colon-three.com/kodi/amethyst",
|
||||
docs: [
|
||||
main: "readme",
|
||||
extras: ["../README.md", "../LICENSE.md"]
|
||||
extras: ["../../README.md", "../../LICENSE.md"]
|
||||
]
|
||||
]
|
||||
end
|
||||
@ -38,7 +38,8 @@ defmodule Amethyst.MixProject do
|
||||
[
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||
{:uuid, "~> 1.1"},
|
||||
{:ex_doc, "~> 0.22", only: :dev, runtime: false}
|
||||
{:ex_doc, "~> 0.22", only: :dev, runtime: false},
|
||||
{:jason, "~> 1.4"}
|
||||
]
|
||||
end
|
||||
end
|
||||
|
60
apps/amethyst/test/game_coordinator_test.exs
Normal file
60
apps/amethyst/test/game_coordinator_test.exs
Normal file
@ -0,0 +1,60 @@
|
||||
defmodule GameCoordinatorTestGame do
|
||||
@behaviour Amethyst.Game
|
||||
|
||||
@moduledoc """
|
||||
This module is a sample game for the purpose of
|
||||
testing the GameCoordinator
|
||||
"""
|
||||
|
||||
@impl true
|
||||
def instantiate(supervisor) do
|
||||
{:ok, %{test: "test"}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def login(from, cfg, refs) do
|
||||
:accept
|
||||
end
|
||||
|
||||
@impl true
|
||||
def joinable?(refs) do
|
||||
true
|
||||
end
|
||||
|
||||
@impl true
|
||||
def player_position(from, {x, y, z}, refs) do
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defmodule GameCoordinatorTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
@moduledoc """
|
||||
This module includes tests for Amethyst.GameCoordinator
|
||||
"""
|
||||
|
||||
test "Create an instance of a game with create/1 then try to find it" do
|
||||
%Amethyst.GameCoordinator.Game{refs: refs, gid: gid, mod: mod} = Amethyst.GameCoordinator.create(GameCoordinatorTestGame)
|
||||
assert mod == GameCoordinatorTestGame
|
||||
assert refs[:test] == "test"
|
||||
|
||||
found_game = Amethyst.GameCoordinator.find(GameCoordinatorTestGame)
|
||||
assert found_game.mod == GameCoordinatorTestGame
|
||||
assert found_game.refs[:test] == "test"
|
||||
assert found_game.gid == gid
|
||||
Amethyst.GameCoordinator.remove(gid)
|
||||
end
|
||||
|
||||
test "Create an instance of a game with find_or_create/1 and then find it with find_or_create/1" do
|
||||
%Amethyst.GameCoordinator.Game{refs: refs, gid: gid, mod: mod} = Amethyst.GameCoordinator.find_or_create(GameCoordinatorTestGame)
|
||||
assert mod == GameCoordinatorTestGame
|
||||
assert refs[:test] == "test"
|
||||
|
||||
found_game = Amethyst.GameCoordinator.find_or_create(GameCoordinatorTestGame)
|
||||
assert found_game.mod == GameCoordinatorTestGame
|
||||
assert found_game.refs[:test] == "test"
|
||||
assert found_game.gid == gid
|
||||
Amethyst.GameCoordinator.remove(gid)
|
||||
end
|
||||
end
|
188
apps/example_game/.credo.exs
Normal file
188
apps/example_game/.credo.exs
Normal file
@ -0,0 +1,188 @@
|
||||
%{
|
||||
#
|
||||
# You can have as many configs as you like in the `configs:` field.
|
||||
configs: [
|
||||
%{
|
||||
#
|
||||
# Run any config using `mix credo -C <name>`. If no config name is given
|
||||
# "default" is used.
|
||||
#
|
||||
name: "default",
|
||||
#
|
||||
# These are the files included in the analysis:
|
||||
files: %{
|
||||
#
|
||||
# You can give explicit globs or simply directories.
|
||||
# In the latter case `**/*.{ex,exs}` will be used.
|
||||
#
|
||||
included: [
|
||||
"lib/",
|
||||
"src/",
|
||||
"test/",
|
||||
],
|
||||
excluded: [~r"/_build/", ~r"/deps/"]
|
||||
},
|
||||
plugins: [],
|
||||
requires: [],
|
||||
strict: false,
|
||||
parse_timeout: 5000,
|
||||
color: true,
|
||||
#
|
||||
# You can customize the parameters of any check by adding a second element
|
||||
# to the tuple.
|
||||
#
|
||||
# To disable a check put `false` as second element:
|
||||
#
|
||||
# {Credo.Check.Design.DuplicatedCode, false}
|
||||
#
|
||||
checks: %{
|
||||
enabled: [
|
||||
#
|
||||
## Consistency Checks
|
||||
#
|
||||
{Credo.Check.Consistency.ExceptionNames, []},
|
||||
{Credo.Check.Consistency.LineEndings, []},
|
||||
{Credo.Check.Consistency.ParameterPatternMatching, []},
|
||||
{Credo.Check.Consistency.SpaceAroundOperators, []},
|
||||
{Credo.Check.Consistency.SpaceInParentheses, []},
|
||||
{Credo.Check.Consistency.TabsOrSpaces, []},
|
||||
|
||||
#
|
||||
## Design Checks
|
||||
#
|
||||
# You can customize the priority of any check
|
||||
# Priority values are: `low, normal, high, higher`
|
||||
#
|
||||
{Credo.Check.Design.AliasUsage,
|
||||
[priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
|
||||
{Credo.Check.Design.TagFIXME, []},
|
||||
# You can also customize the exit_status of each check.
|
||||
# If you don't want TODO comments to cause `mix credo` to fail, just
|
||||
# set this value to 0 (zero).
|
||||
#
|
||||
{Credo.Check.Design.TagTODO, [exit_status: 0]},
|
||||
|
||||
#
|
||||
## Readability Checks
|
||||
#
|
||||
{Credo.Check.Readability.AliasOrder, []},
|
||||
{Credo.Check.Readability.FunctionNames, []},
|
||||
{Credo.Check.Readability.LargeNumbers, []},
|
||||
{Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]},
|
||||
{Credo.Check.Readability.ModuleAttributeNames, []},
|
||||
{Credo.Check.Readability.ModuleDoc, []},
|
||||
{Credo.Check.Readability.ModuleNames, []},
|
||||
{Credo.Check.Readability.ParenthesesInCondition, []},
|
||||
{Credo.Check.Readability.ParenthesesOnZeroArityDefs, []},
|
||||
{Credo.Check.Readability.PipeIntoAnonymousFunctions, []},
|
||||
{Credo.Check.Readability.PredicateFunctionNames, []},
|
||||
{Credo.Check.Readability.PreferImplicitTry, []},
|
||||
{Credo.Check.Readability.RedundantBlankLines, []},
|
||||
{Credo.Check.Readability.Semicolons, []},
|
||||
{Credo.Check.Readability.SpaceAfterCommas, []},
|
||||
{Credo.Check.Readability.StringSigils, []},
|
||||
{Credo.Check.Readability.TrailingBlankLine, []},
|
||||
{Credo.Check.Readability.TrailingWhiteSpace, []},
|
||||
{Credo.Check.Readability.UnnecessaryAliasExpansion, []},
|
||||
{Credo.Check.Readability.VariableNames, []},
|
||||
{Credo.Check.Readability.WithSingleClause, []},
|
||||
|
||||
#
|
||||
## Refactoring Opportunities
|
||||
#
|
||||
{Credo.Check.Refactor.Apply, []},
|
||||
{Credo.Check.Refactor.CondStatements, []},
|
||||
{Credo.Check.Refactor.CyclomaticComplexity, []},
|
||||
{Credo.Check.Refactor.FilterCount, []},
|
||||
{Credo.Check.Refactor.FilterFilter, []},
|
||||
{Credo.Check.Refactor.FunctionArity, []},
|
||||
{Credo.Check.Refactor.LongQuoteBlocks, []},
|
||||
{Credo.Check.Refactor.MapJoin, []},
|
||||
{Credo.Check.Refactor.MatchInCondition, []},
|
||||
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
|
||||
{Credo.Check.Refactor.NegatedConditionsWithElse, []},
|
||||
{Credo.Check.Refactor.Nesting, []},
|
||||
{Credo.Check.Refactor.RedundantWithClauseResult, []},
|
||||
{Credo.Check.Refactor.RejectReject, []},
|
||||
{Credo.Check.Refactor.UnlessWithElse, []},
|
||||
{Credo.Check.Refactor.WithClauses, []},
|
||||
|
||||
#
|
||||
## Warnings
|
||||
#
|
||||
{Credo.Check.Warning.ApplicationConfigInModuleAttribute, []},
|
||||
{Credo.Check.Warning.BoolOperationOnSameValues, []},
|
||||
{Credo.Check.Warning.Dbg, []},
|
||||
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
|
||||
{Credo.Check.Warning.IExPry, []},
|
||||
{Credo.Check.Warning.IoInspect, []},
|
||||
{Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []},
|
||||
{Credo.Check.Warning.OperationOnSameValues, []},
|
||||
{Credo.Check.Warning.OperationWithConstantResult, []},
|
||||
{Credo.Check.Warning.RaiseInsideRescue, []},
|
||||
{Credo.Check.Warning.SpecWithStruct, []},
|
||||
{Credo.Check.Warning.UnsafeExec, []},
|
||||
{Credo.Check.Warning.UnusedEnumOperation, []},
|
||||
{Credo.Check.Warning.UnusedFileOperation, []},
|
||||
{Credo.Check.Warning.UnusedKeywordOperation, []},
|
||||
{Credo.Check.Warning.UnusedListOperation, []},
|
||||
{Credo.Check.Warning.UnusedPathOperation, []},
|
||||
{Credo.Check.Warning.UnusedRegexOperation, []},
|
||||
{Credo.Check.Warning.UnusedStringOperation, []},
|
||||
{Credo.Check.Warning.UnusedTupleOperation, []},
|
||||
{Credo.Check.Warning.WrongTestFileExtension, []}
|
||||
],
|
||||
disabled: [
|
||||
#
|
||||
# Checks scheduled for next check update (opt-in for now)
|
||||
{Credo.Check.Refactor.UtcNowTruncate, []},
|
||||
|
||||
#
|
||||
# Controversial and experimental checks (opt-in, just move the check to `:enabled`
|
||||
# and be sure to use `mix credo --strict` to see low priority checks)
|
||||
#
|
||||
{Credo.Check.Consistency.MultiAliasImportRequireUse, []},
|
||||
{Credo.Check.Consistency.UnusedVariableNames, []},
|
||||
{Credo.Check.Design.DuplicatedCode, []},
|
||||
{Credo.Check.Design.SkipTestWithoutComment, []},
|
||||
{Credo.Check.Readability.AliasAs, []},
|
||||
{Credo.Check.Readability.BlockPipe, []},
|
||||
{Credo.Check.Readability.ImplTrue, []},
|
||||
{Credo.Check.Readability.MultiAlias, []},
|
||||
{Credo.Check.Readability.NestedFunctionCalls, []},
|
||||
{Credo.Check.Readability.OneArityFunctionInPipe, []},
|
||||
{Credo.Check.Readability.OnePipePerLine, []},
|
||||
{Credo.Check.Readability.SeparateAliasRequire, []},
|
||||
{Credo.Check.Readability.SingleFunctionToBlockPipe, []},
|
||||
{Credo.Check.Readability.SinglePipe, []},
|
||||
{Credo.Check.Readability.Specs, []},
|
||||
{Credo.Check.Readability.StrictModuleLayout, []},
|
||||
{Credo.Check.Readability.WithCustomTaggedTuple, []},
|
||||
{Credo.Check.Refactor.ABCSize, []},
|
||||
{Credo.Check.Refactor.AppendSingleItem, []},
|
||||
{Credo.Check.Refactor.DoubleBooleanNegation, []},
|
||||
{Credo.Check.Refactor.FilterReject, []},
|
||||
{Credo.Check.Refactor.IoPuts, []},
|
||||
{Credo.Check.Refactor.MapMap, []},
|
||||
{Credo.Check.Refactor.ModuleDependencies, []},
|
||||
{Credo.Check.Refactor.NegatedIsNil, []},
|
||||
{Credo.Check.Refactor.PassAsyncInTestCases, []},
|
||||
{Credo.Check.Refactor.PipeChainStart, []},
|
||||
{Credo.Check.Refactor.RejectFilter, []},
|
||||
{Credo.Check.Refactor.VariableRebinding, []},
|
||||
{Credo.Check.Warning.LazyLogging, []},
|
||||
{Credo.Check.Warning.LeakyEnvironment, []},
|
||||
{Credo.Check.Warning.MapGetUnsafePass, []},
|
||||
{Credo.Check.Warning.MixEnv, []},
|
||||
{Credo.Check.Warning.UnsafeToAtom, []}
|
||||
|
||||
# {Credo.Check.Refactor.MapInto, []},
|
||||
|
||||
#
|
||||
# Custom checks can be created using `mix credo.gen.check`.
|
||||
#
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
4
apps/example_game/.formatter.exs
Normal file
4
apps/example_game/.formatter.exs
Normal file
@ -0,0 +1,4 @@
|
||||
# Used by "mix format"
|
||||
[
|
||||
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
||||
]
|
26
apps/example_game/.gitignore
vendored
Normal file
26
apps/example_game/.gitignore
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
# The directory Mix will write compiled artifacts to.
|
||||
/_build/
|
||||
|
||||
# If you run "mix test --cover", coverage assets end up here.
|
||||
/cover/
|
||||
|
||||
# The directory Mix downloads your dependencies sources to.
|
||||
/deps/
|
||||
|
||||
# Where third-party dependencies like ExDoc output generated docs.
|
||||
/doc/
|
||||
|
||||
# Ignore .fetch files in case you like to edit your project deps locally.
|
||||
/.fetch
|
||||
|
||||
# If the VM crashes, it generates a dump, let's ignore it too.
|
||||
erl_crash.dump
|
||||
|
||||
# Also ignore archive artifacts (built via "mix archive.build").
|
||||
*.ez
|
||||
|
||||
# Ignore package tarball (built via "mix hex.build").
|
||||
example_game-*.tar
|
||||
|
||||
# Temporary files, for example, from tests.
|
||||
/tmp/
|
3
apps/example_game/README.md
Normal file
3
apps/example_game/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Example Game
|
||||
|
||||
This module is a simple game which is made to aid in designing and testing Amethyst's APIs.
|
18
apps/example_game/lib/example/application.ex
Normal file
18
apps/example_game/lib/example/application.ex
Normal file
@ -0,0 +1,18 @@
|
||||
defmodule Example.Application do
|
||||
# See https://hexdocs.pm/elixir/Application.html
|
||||
# for more information on OTP Applications
|
||||
@moduledoc false
|
||||
|
||||
use Application
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
]
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
# for other strategies and supported options
|
||||
opts = [strategy: :one_for_one, name: Example.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
end
|
58
apps/example_game/lib/example/game.ex
Normal file
58
apps/example_game/lib/example/game.ex
Normal file
@ -0,0 +1,58 @@
|
||||
defmodule Example.Game do
|
||||
require Logger
|
||||
@behaviour Amethyst.Game
|
||||
|
||||
@impl true
|
||||
def instantiate(supervisor) do
|
||||
Logger.info("The supervisor for this game is at #{inspect(supervisor)}")
|
||||
{:ok, %{}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def login(from, cfg, refs) do
|
||||
Logger.info("Player logged in from #{inspect(from)}: #{inspect(cfg)}")
|
||||
Logger.info("The refs for this game are #{inspect(refs)}")
|
||||
{:accept, {0.0, 270.0, 0.0}, {0.0, 0.0}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
@spec player_position(any(), {any(), any(), any()}, any()) :: :ok
|
||||
def player_position(from, {x, y, z}, _refs) do
|
||||
# Logger.info("Player at #{inspect(from)} moved to #{x}, #{y}, #{z}")
|
||||
send(from, {:set_position, {x, y, z}})
|
||||
:ok
|
||||
end
|
||||
|
||||
@impl true
|
||||
def player_rotation(_from, {_yaw, _pitch}, _refs) do
|
||||
# Logger.info("Player at #{inspect(from)} rotated to #{yaw}, #{pitch}")
|
||||
:ok
|
||||
end
|
||||
|
||||
@impl true
|
||||
def accept_teleport(from, id, _state_refs) do
|
||||
Logger.info("Player at #{inspect(from)} accepted teleport #{inspect(id)}")
|
||||
:ok
|
||||
end
|
||||
|
||||
@impl true
|
||||
def joinable?(_refs) do
|
||||
true
|
||||
end
|
||||
|
||||
@impl true
|
||||
def chunk(_from, {_cx, _cz}, _state_refs) do
|
||||
# Logger.info("Player at #{inspect(from)} wants to know chunk #{cx}, #{cz}")
|
||||
(0..255) |> Enum.map(fn y ->
|
||||
(0..15) |> Enum.map(fn z ->
|
||||
(0..15) |> Enum.map(fn x ->
|
||||
if y <= x + z do
|
||||
3
|
||||
else
|
||||
0
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end
|
32
apps/example_game/mix.exs
Normal file
32
apps/example_game/mix.exs
Normal file
@ -0,0 +1,32 @@
|
||||
defmodule Example.MixProject do
|
||||
use Mix.Project
|
||||
|
||||
def project do
|
||||
[
|
||||
app: :example_game,
|
||||
version: "0.1.0",
|
||||
build_path: "../../_build",
|
||||
config_path: "../../config/config.exs",
|
||||
deps_path: "../../deps",
|
||||
lockfile: "../../mix.lock",
|
||||
elixir: "~> 1.17",
|
||||
start_permanent: Mix.env() == :prod,
|
||||
deps: deps()
|
||||
]
|
||||
end
|
||||
|
||||
# Run "mix help compile.app" to learn about applications.
|
||||
def application do
|
||||
[
|
||||
extra_applications: [:logger],
|
||||
mod: {Example.Application, []}
|
||||
]
|
||||
end
|
||||
|
||||
# Run "mix help deps" to learn about dependencies.
|
||||
defp deps do
|
||||
[
|
||||
{:amethyst, in_umbrella: true}
|
||||
]
|
||||
end
|
||||
end
|
@ -8,11 +8,7 @@
|
||||
# configurations or dependencies per app, it is best to
|
||||
# move said applications out of the umbrella.
|
||||
import Config
|
||||
|
||||
# Sample configuration:
|
||||
#
|
||||
# config :logger, :console,
|
||||
# level: :info,
|
||||
# format: "$date $time [$level] $metadata$message\n",
|
||||
# metadata: [:user_id]
|
||||
#
|
||||
config :logger, :console,
|
||||
level: :debug,
|
||||
format: "$date $time [$level] $metadata$message\n",
|
||||
metadata: []
|
||||
|
@ -3,4 +3,5 @@ import Config
|
||||
config :amethyst,
|
||||
port: 25599, # Bogus port for testing, avoids unexpected conflicts
|
||||
encryption: false, # Whether or not to request encryption from clients.
|
||||
auth: false # Whether or not users should be authenticated with Mojang.
|
||||
auth: false, # Whether or not users should be authenticated with Mojang.
|
||||
default_game: Example.Game # Which game new players should be sent to
|
||||
|
@ -57,7 +57,7 @@
|
||||
}).overrideAttrs (old: {
|
||||
preInstall = ''
|
||||
# Run automated tests
|
||||
mix test --no-deps-check --no-start --color
|
||||
mix test --no-deps-check --color
|
||||
'';
|
||||
});
|
||||
|
||||
@ -83,6 +83,8 @@
|
||||
elixir
|
||||
elixir-ls
|
||||
mix2nix
|
||||
|
||||
pre-commit
|
||||
];
|
||||
};
|
||||
}
|
||||
|
10
mix.exs
10
mix.exs
@ -8,11 +8,19 @@ defmodule AmethystUmbrella.MixProject do
|
||||
elixir: "~> 1.17",
|
||||
start_permanent: Mix.env() == :prod,
|
||||
deps: deps(),
|
||||
default_release: :amethyst,
|
||||
|
||||
releases: [
|
||||
amethyst: [
|
||||
version: "0.1.0",
|
||||
applications: [amethyst: :permanent]
|
||||
],
|
||||
example: [
|
||||
version: "0.1.0",
|
||||
applications: [
|
||||
amethyst: :permanent,
|
||||
example_game: :permanent
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
@ -24,6 +32,6 @@ defmodule AmethystUmbrella.MixProject do
|
||||
#
|
||||
# Run "mix help deps" for examples and options.
|
||||
defp deps do
|
||||
[]
|
||||
[{:pre_commit, "~> 0.3.4", only: :dev}]
|
||||
end
|
||||
end
|
||||
|
2
mix.lock
2
mix.lock
@ -2,6 +2,7 @@
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
"credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"},
|
||||
"earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"},
|
||||
"elixir_math": {:hex, :elixir_math, "0.1.2", "5655bdf7f34e30906f31cdcf3031b43dd522ce8d2936b60ad4696b2c752bf5c9", [:mix], [], "hexpm", "34f4e4384903097a8ec566784fa8e9aa2b741247d225741f07cc48250c2aa64c"},
|
||||
"ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"},
|
||||
"file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"},
|
||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||
@ -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_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"},
|
||||
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
|
||||
"pre_commit": {:hex, :pre_commit, "0.3.4", "e2850f80be8090d50ad8019ef2426039307ff5dfbe70c736ad0d4d401facf304", [:mix], [], "hexpm", "16f684ba4f1fed1cba6b19e082b0f8d696e6f1c679285fedf442296617ba5f4e"},
|
||||
"uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"},
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user