diff --git a/apps/amethyst/lib/api/game.ex b/apps/amethyst/lib/api/game.ex index b1df089..55baefb 100644 --- a/apps/amethyst/lib/api/game.ex +++ b/apps/amethyst/lib/api/game.ex @@ -19,26 +19,42 @@ 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 - %{ - id: __MODULE__, - start: {__MODULE__, :register_self, []} - } - end - def register_self() do - Amethyst.GameRegistry.register(__MODULE__, unquote(meta)) - Process.sleep(:infinity) - end - end - end + @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 %{ diff --git a/apps/amethyst/lib/apps/game_coordinator.ex b/apps/amethyst/lib/apps/game_coordinator.ex index e53ec66..23a876f 100644 --- a/apps/amethyst/lib/apps/game_coordinator.ex +++ b/apps/amethyst/lib/apps/game_coordinator.ex @@ -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,23 +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) - {:ok, pid} = DynamicSupervisor.start_child( - game_supervisor_pid, - Amethyst.API.Game.child_spec(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 diff --git a/apps/amethyst/lib/apps/game_registry.ex b/apps/amethyst/lib/apps/game_registry.ex deleted file mode 100644 index cd9a575..0000000 --- a/apps/amethyst/lib/apps/game_registry.ex +++ /dev/null @@ -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 . - -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 diff --git a/apps/amethyst/lib/servers/configuration.ex b/apps/amethyst/lib/servers/configuration.ex index 36e56c2..74957da 100644 --- a/apps/amethyst/lib/servers/configuration.ex +++ b/apps/amethyst/lib/servers/configuration.ex @@ -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 diff --git a/apps/amethyst/mix.exs b/apps/amethyst/mix.exs index 7dd58d7..b38a215 100644 --- a/apps/amethyst/mix.exs +++ b/apps/amethyst/mix.exs @@ -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 diff --git a/apps/example_game/lib/example/application.ex b/apps/example_game/lib/example/application.ex index c9eaf22..838d0ae 100644 --- a/apps/example_game/lib/example/application.ex +++ b/apps/example_game/lib/example/application.ex @@ -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 diff --git a/apps/example_game/lib/example/game.ex b/apps/example_game/lib/example/game.ex index 6c7bdb7..2146abb 100644 --- a/apps/example_game/lib/example/game.ex +++ b/apps/example_game/lib/example/game.ex @@ -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 diff --git a/config/runtime.exs b/config/runtime.exs index 0b68390..f36f7b8 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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