Compare commits

...

2 Commits

Author SHA1 Message Date
4a5ccc719d
Some significant refactoring progress
Some checks failed
Build & Test / nix-build (push) Failing after 59s
2024-08-28 14:52:13 +02:00
fb2a21a546
Run all game processes in a game supervisor 2024-08-25 21:03:09 +02:00
8 changed files with 97 additions and 114 deletions

View File

@ -19,31 +19,62 @@ defmodule Amethyst.API.Game do
This module includes the interface for defining and registering This module includes the interface for defining and registering
a game with Amethyst. a game with Amethyst.
""" """
@callback instantiate(supervisor :: pid()) :: {:ok, state_refs :: map()} | {:error, reason :: term}
@callback login(from :: pid(), player_cfg :: keyword(), state_refs :: map()) :: :ok
@callback player_position(from :: pid(), {x :: float(), y :: float(), z :: float()}, state_refs :: map()) :: :ok
defmacro __using__(opts) do @doc """
meta = Keyword.get(opts, :meta, []) `instantiate/1` is called when a new instance of your game is created. You may start any additional
quote do processes you want under the `DynamicSupervisor` 'supervisor' and return a map with the PIDs to those
@behaviour Amethyst.API.Game processes, known as your references. Other callbacks in your game will receive these references and
def child_spec(state) do may use them to access and update state.
%{ """
id: __MODULE__, @callback instantiate(supervisor :: pid()) :: {:ok, state_refs :: map()} | {:error, reason :: term}
start: {__MODULE__, :register_self, []} @doc """
} `login/3` is called when a new player logs into a game instance.
end You may either :accept or :reject the player for whatever reason, avoid
def register_self() do rejecting the player in your default game as that will disconnect the player.
Amethyst.GameRegistry.register(__MODULE__, unquote(meta)) The PID received in 'from' will be the one that calls all callbacks
Process.sleep(:infinity) caused directly by this player, such as their movement or interactions --
end You can expect that PID to never be used by any other player and that this same
end 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 | :reject
@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
@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
"""
@callback joinable?(state_refs :: map()) :: boolean()
def child_spec(mod, refs) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [mod, refs]}
}
end end
@spec start(Amethyst.API.Game, term()) :: no_return() @spec start(Amethyst.API.Game, term()) :: {:ok, pid}
def start(mod, refs) do def start(mod, refs) do
true = refs |> Enum.all?(fn {_key, pid } -> Process.link(pid) end) pid = spawn(fn ->
loop(mod, refs) true = refs |> Enum.all?(fn {_key, pid } -> Process.link(pid) end)
loop(mod, refs)
end)
{:ok, pid}
end
def start_link(mod, refs) do
{:ok, pid} = start(mod, refs)
Process.link(pid)
{:ok, pid}
end end
defp loop(mod, refs) do defp loop(mod, refs) do
receive do receive do

View File

@ -22,37 +22,59 @@ defmodule Amethyst.GameCoordinator do
instances of each game and can create new ones on demand. instances of each game and can create new ones on demand.
""" """
def start_link(initial) when is_list(initial) do 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: []
end
@spec start_link(State) :: {:ok, pid()}
def start_link(initial) do
GenServer.start_link(__MODULE__, initial, name: {:global, __MODULE__}) GenServer.start_link(__MODULE__, initial, name: {:global, __MODULE__})
end end
@impl true @impl true
@spec init(State) :: {:ok, State}
def init(initial) do def init(initial) do
{:ok, initial} {:ok, initial}
end end
@impl true @impl true
def handle_call({:find, type}, _from, state) do def handle_call({:find, type}, _from, state) do
{pid, state} = _find(type, state) {game, state} = _find(type, state)
{:reply, pid, state} {:reply, game, state}
end end
@impl true @impl true
def handle_call({:create, type}, _from, state) do def handle_call({:create, type}, _from, state) do
{pid, state} = _create(type, state) {game, state} = _create(type, state)
{:reply, pid, state} {:reply, game, state}
end end
@impl true @impl true
def handle_call({:find_or_create, type}, from, state) do def handle_call({:find_or_create, type}, from, state) do
{pid, state} = _find(type, state) {game, state} = _find(type, state)
case pid do case game do
nil -> handle_call({:create, type}, from, state) nil -> handle_call({:create, type}, from, state)
some -> {:reply, some, state} some -> {:reply, some, state}
end end
end end
defp _create(type, games) do @spec _create(atom(), State.t()) :: {Game.t(), State.t()}
defp _create(type, state) do
# Create a DynamicSupervisor for this game # Create a DynamicSupervisor for this game
{:ok, game_supervisor_pid} = DynamicSupervisor.start_child( {:ok, game_supervisor_pid} = DynamicSupervisor.start_child(
{:via, PartitionSupervisor, {Amethyst.GameMetaSupervisor, type}}, {:via, PartitionSupervisor, {Amethyst.GameMetaSupervisor, type}},
@ -66,20 +88,19 @@ defmodule Amethyst.GameCoordinator do
# We should gracefully handle situations where we cannot create a game. # We should gracefully handle situations where we cannot create a game.
{:ok, refs} = type.instantiate(game_supervisor_pid) {:ok, refs} = type.instantiate(game_supervisor_pid)
refs = refs |> Map.put(:game_supervisor, game_supervisor_pid) |> Map.put(:task_supervisor, task_supervisor_pid) refs = refs |> Map.put(:game_supervisor, game_supervisor_pid) |> Map.put(:task_supervisor, task_supervisor_pid)
pid = spawn(Amethyst.API.Game, :start, [type, refs]) game = %Game{
games = [{type, pid, []} | games] mod: type, refs: refs, opts: []
{pid, games} }
games = state.games |> Map.put(state.gid, game)
{game, %State{gid: state.gid + 1, games: games}}
end end
defp _find(type, games) do defp _find(type, state) do
alive_games = games |> Enum.filter(fn {_mod, pid, _opts} -> Process.alive?(pid) end)
# TODO: Here we should have some additional filtering for specifically joinable games
existing = alive_games |> Enum.filter(fn {mod, _pid, _opts} -> mod == type end)
if length(existing) > 0 do if length(existing) > 0 do
[{_mod, pid, _} | _] = existing [{_mod, pid, _} | _] = existing
{pid, alive_games} {pid, games}
else else
{nil, alive_games} {nil, games}
end end
end end

View File

@ -1,70 +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.GameRegistry do
use GenServer
@moduledoc """
The game registry stores information about which games are
available.
"""
@impl true
def init(initial) do
{:ok, initial}
end
@impl true
def handle_call({:register, module, meta}, _, state) do
if Keyword.get(meta, :default, false) && find_default(state) != nil do
{:reply, {:error, :default_exists}, state}
else
{:reply, :ok, [{module, meta} | state]}
end
end
@impl true
def handle_call({:list}, _, state) do
{:reply, state, state}
end
@impl true
def handle_call({:get_default}, _, state) do
{:reply, find_default(state), state}
end
defp find_default(state) do
state |> Enum.filter(fn {_mod, meta} ->
Keyword.get(meta, :default, false)
end) |> List.first(nil)
end
def start_link(initial) when is_list(initial) do
GenServer.start_link(__MODULE__, initial, name: {:global, __MODULE__})
end
def register(module, meta) do
GenServer.call({:global, __MODULE__}, {:register, module, meta})
end
def list() do
GenServer.call({:global, __MODULE__}, {:list})
end
def get_default() do
GenServer.call({:global, __MODULE__}, {:get_default})
end
end

View File

@ -327,7 +327,7 @@ defmodule Amethyst.Server.Configuration do
end end
# Acknowledge Finish Configuration https://wiki.vg/Protocol#Acknowledge_Finish_Configuration # Acknowledge Finish Configuration https://wiki.vg/Protocol#Acknowledge_Finish_Configuration
def handle({:acknowledge_finish_configuration}, client, state) do def handle({:acknowledge_finish_configuration}, client, state) do
game = Amethyst.GameRegistry.get_default() |> elem(0) |> Amethyst.GameCoordinator.find_or_create() game = Application.fetch_env!(:amethyst, :default_game) |> Amethyst.GameCoordinator.find_or_create()
state = Keyword.put(state, :game, game) state = Keyword.put(state, :game, game)
Amethyst.API.Game.login(game, state) Amethyst.API.Game.login(game, state)
# TODO: All of this stuff should obviously not be hardcoded here # TODO: All of this stuff should obviously not be hardcoded here

View File

@ -20,7 +20,7 @@ defmodule Amethyst.MixProject do
source_url: "https://git.colon-three.com/kodi/amethyst", source_url: "https://git.colon-three.com/kodi/amethyst",
docs: [ docs: [
main: "readme", main: "readme",
extras: ["../README.md", "../LICENSE.md"] extras: ["../../README.md", "../../LICENSE.md"]
] ]
] ]
end end

View File

@ -8,7 +8,6 @@ defmodule Example.Application do
@impl true @impl true
def start(_type, _args) do def start(_type, _args) do
children = [ children = [
{Example.Game, {}}
] ]
# See https://hexdocs.pm/elixir/Supervisor.html # See https://hexdocs.pm/elixir/Supervisor.html

View File

@ -1,6 +1,6 @@
defmodule Example.Game do defmodule Example.Game do
require Logger require Logger
use Amethyst.API.Game, meta: [default: true] @behaviour Amethyst.API.Game
@impl true @impl true
def instantiate(supervisor) do def instantiate(supervisor) do
@ -9,8 +9,9 @@ defmodule Example.Game do
end end
@impl true @impl true
def login(from, cfg, _state) do def login(from, cfg, refs) do
Logger.info("Player logged in from #{inspect(from)}: #{inspect(cfg)}") Logger.info("Player logged in from #{inspect(from)}: #{inspect(cfg)}")
Logger.info("The refs for this game are #{inspect(refs)}")
:ok :ok
end end
end end

View File

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