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
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
meta = Keyword.get(opts, :meta, [])
quote do
@behaviour Amethyst.API.Game
def child_spec(state) do
@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.
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 | :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__, :register_self, []}
start: {__MODULE__, :start_link, [mod, refs]}
}
end
def register_self() do
Amethyst.GameRegistry.register(__MODULE__, unquote(meta))
Process.sleep(:infinity)
end
end
end
@spec start(Amethyst.API.Game, term()) :: no_return()
@spec start(Amethyst.API.Game, term()) :: {:ok, pid}
def start(mod, refs) do
pid = spawn(fn ->
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
defp loop(mod, refs) do
receive do

View File

@ -22,37 +22,59 @@ defmodule Amethyst.GameCoordinator do
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__})
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
{pid, state} = _find(type, state)
{:reply, pid, state}
{game, state} = _find(type, state)
{:reply, game, state}
end
@impl true
def handle_call({:create, type}, _from, state) do
{pid, state} = _create(type, state)
{:reply, pid, state}
{game, state} = _create(type, state)
{:reply, game, state}
end
@impl true
def handle_call({:find_or_create, type}, from, state) do
{pid, state} = _find(type, state)
case pid do
{game, state} = _find(type, state)
case game do
nil -> handle_call({:create, type}, from, state)
some -> {:reply, some, state}
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
{:ok, game_supervisor_pid} = DynamicSupervisor.start_child(
{:via, PartitionSupervisor, {Amethyst.GameMetaSupervisor, type}},
@ -66,20 +88,19 @@ defmodule Amethyst.GameCoordinator do
# 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)
pid = spawn(Amethyst.API.Game, :start, [type, refs])
games = [{type, pid, []} | games]
{pid, games}
game = %Game{
mod: type, refs: refs, opts: []
}
games = state.games |> Map.put(state.gid, game)
{game, %State{gid: state.gid + 1, games: games}}
end
defp _find(type, games) 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)
defp _find(type, state) do
if length(existing) > 0 do
[{_mod, pid, _} | _] = existing
{pid, alive_games}
{pid, games}
else
{nil, alive_games}
{nil, games}
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
# Acknowledge Finish Configuration https://wiki.vg/Protocol#Acknowledge_Finish_Configuration
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)
Amethyst.API.Game.login(game, state)
# 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",
docs: [
main: "readme",
extras: ["../README.md", "../LICENSE.md"]
extras: ["../../README.md", "../../LICENSE.md"]
]
]
end

View File

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

View File

@ -1,6 +1,6 @@
defmodule Example.Game do
require Logger
use Amethyst.API.Game, meta: [default: true]
@behaviour Amethyst.API.Game
@impl true
def instantiate(supervisor) do
@ -9,8 +9,9 @@ defmodule Example.Game do
end
@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("The refs for this game are #{inspect(refs)}")
:ok
end
end

View File

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