defmodule Mogs.Board.Server.Config do @moduledoc false # Board options require Record Record.defrecord(:rcfg, timers: false, tracker: nil) end defmodule Mogs.Board.Server do use TODO use GenServer, restart: :transient require Logger require Record alias Mogs.Board.Command.Result alias Mogs.Players.Tracker import Mogs.Board.Server.Config, only: [rcfg: 0, rcfg: 1, rcfg: 2], warn: false # GenServer state # # tref is a 2-tuple holding two time references : a TimeQueue tref and an # :erlang tref. Record.defrecordp(:s, id: nil, mod: nil, board: nil, tref: {nil, nil}, cfg: rcfg(), tracker: nil) # @todo allow to define the timeout from the `use Mogs.Board` call @timeout 60_000 @lifecycle {:continue, :lifecycle} @todo "Proper options validation must be done here, it is the last moment to set defaults" def start_link(opts) when is_list(opts) do {opts, cfg_opts} = Keyword.split(opts, [:mod, :id, :name, :load_info]) mod = Keyword.fetch!(opts, :mod) id = Keyword.fetch!(opts, :id) name = Keyword.fetch!(opts, :name) load_info = Keyword.get(opts, :load_info, nil) cfg = load_board_config(cfg_opts, rcfg()) tracker = case rcfg(cfg, :tracker) do nil -> nil tracker_opts when is_list(tracker_opts) -> Tracker.new(tracker_opts) end GenServer.start_link(__MODULE__, {mod, id, load_info, cfg, tracker}, name: name) end defp load_board_config([], cfg) do cfg end defp load_board_config([{k, v} | opts], cfg) do load_board_config(opts, load_board_config_elem(k, v, cfg)) end defp load_board_config_elem(:timers, v, cfg) when is_boolean(v) do rcfg(cfg, timers: v) end defp load_board_config_elem(:tracker, v, cfg) when is_list(v) do rcfg(cfg, tracker: v) end defp load_board_config_elem(:tracker, nil, cfg) do rcfg(cfg, tracker: nil) end defp load_board_config_elem(k, v, cfg) do Logger.warn("Ignored Invalid #{__MODULE__} config options: #{inspect(k)}=#{inspect(v)}") cfg end @impl true def init({mod, id, load_info, cfg, tracker}) do with :sync <- load_mode(mod), {:ok, board} <- load_board(mod, id, load_info) do {:ok, s(id: id, mod: mod, board: board, cfg: cfg, tracker: tracker), @lifecycle} else :async -> {:ok, s(id: id, mod: mod, board: load_info, cfg: cfg, tracker: tracker), {:continue, :async_load}} {:error, reason} -> {:stop, reason} end end @impl true def handle_continue(:async_load, s(id: id, mod: mod, board: load_info) = state) do case load_board(mod, id, load_info) do {:ok, board} -> {:noreply, s(state, board: board), @lifecycle} {:error, reason} -> {:stop, reason} end end def handle_continue(:lifecycle, state) do do_lifecycle(state) end @impl true def handle_call({:read_board_state, fun}, _from, s(board: board) = state) do {:reply, fun.(board), state, @timeout} end @impl true def handle_call({:run_command, command}, from, s(board: board, mod: mod) = state) do handle_result(mod.handle_command(command, board), {true, from}, state) end @impl true def handle_call({:add_player, player_id, data, pid}, from, s(board: board, mod: mod) = state) do case mod.handle_add_player(board, player_id, data) do {:error, _} = err -> todo "Should we run lifecycle on error? Maybe just handle result" {:reply, err, state, @timeout} {:ok, board} -> tracker = s(state, :tracker) tracker = case pid do nil -> tracker pid when is_pid(pid) -> Tracker.track(tracker, player_id, pid) end handle_result( %Result{board: board, ok?: true, reply: :ok}, {true, from}, s(state, tracker: tracker) ) end end def handle_call({:track_player, player_id, pid}, _from, s(tracker: tracker) = state) do tracker = Tracker.track(tracker, player_id, pid) {:reply, :ok, s(state, tracker: tracker), @timeout} end @impl true # Receiving a timeout for wich we have a reference in the state. def handle_info({:timeout, erl_tref, msg}, s(tref: {_, erl_tref}) = state) do # cleanup as the ref can no longer been expected to tick state = s(state, tref: {nil, nil}) case msg do :run_lifecycle -> do_lifecycle(state) end end @impl true def handle_info({:timeout, _, {Tracker, _}} = msg, s(tracker: tracker) = state) do case Tracker.handle_timeout(tracker, msg) do :stale -> {:noreply, state, @timeout} {:player_timeout, player_id, tracker} -> s(board: board, mod: mod) = state state = s(state, tracker: tracker) case mod.handle_player_timeout(board, player_id) do {:ok, board} -> handle_result( %Result{board: board, ok?: true, reply: :ok}, false, state ) {:stop, reason} -> {:stop, reason, state} end end end @impl true def handle_info({:timeout, _, msg}, state) do Logger.debug("Ignored erl timeout #{inspect(msg)}") {:noreply, state, @timeout} end @impl true @todo "pass timeout to the board callback module" def handle_info(:timeout, state) do Logger.debug("Ignored #{inspect(__MODULE__)} :timeout") {:noreply, state, @timeout} end def handle_info({:DOWN, _, :process, _, _} = msg, s(tracker: tracker) = state) do case Tracker.handle_down(tracker, msg) do {:ok, tracker} -> {:noreply, s(state, tracker: tracker), @timeout} :unknown -> Logger.warn("Received monitor down message: #{inspect(msg)}") {:noreply, state, @timeout} end end defp handle_result(%Result{} = result, reply_info, s(mod: mod) = state) do # First, send any command reply if needed case reply_info do false -> false {true, gen_from} -> GenServer.reply(gen_from, result.reply) end {callback, args} = case result.ok? do true -> {:handle_update, [result.board]} false -> {:handle_error, [result.reason, result.board]} end case apply(mod, callback, args) do {:ok, board} -> {:noreply, s(state, board: board), @lifecycle} {:stop, reason} -> {:stop, reason, s(state, board: result.board)} end end defp handle_result(not_a_result, _, state) do Logger.error("Command returned invalid result: #{inspect(not_a_result)}") {:stop, {:bad_command_return, not_a_result}, state} end defp load_mode(mod) do mod.__mogs__(:load_mode) end defp load_board(mod, id, load_info) do case mod.load(id, load_info) do {:ok, board} -> {:ok, board} {:error, _reason} = error -> error other -> {:error, {:bad_return, {mod, :load, [id, load_info]}, other}} end end # Managing the state after different events. We implement is as a simple # `with` block that will call all functions of the lifecycle and expect them # to return `:unhandled`, and then just return {:noreply, state, timeout} # if any function returns {:handled, result} we will return the result. # # Any function that "consumes" the loop iteration by returning {:handled,_} # should return a {:continue, :lifecycle} in the reply in order to run the # lifecycle again and again until all clauses returns :unhandled. # # Currently we only have one function defp do_lifecycle(state) do # Syntax with multiple lifetime functions: # with :unhandled <- lf_run_next_timer(state), # :unhandled <- fun_2(state), # :unhandled <- fun_3(state), # :unhandled <- fun_4(state), # :unhandled <- fun_5(state) do # {:noreply, state, @timeout} # else # {:handled, gen_tuple_reply} -> gen_tuple_reply # end # Make credo happy and use a case case lf_run_next_timer(state) do :unhandled -> {:noreply, state, @timeout} {:handled, gen_tuple_reply} -> gen_tuple_reply end end # Lifecycle functions prefixed with "lf_" defp lf_run_next_timer(s(cfg: rcfg(timers: false))) do :unhandled end defp lf_run_next_timer(s(cfg: rcfg(timers: true)) = state) do s(tref: {tq_tref, erl_tref}, board: board) = state case Mogs.Timers.pop_timer(board) do {:ok, entry, board} -> gen_tuple = case TimeQueue.value(entry) do {:mogs_command_timer, command_mod, data} -> handle_result(command_mod.handle_timer(data, board), false, s(state, board: board)) other -> {:stop, {:bad_timer, other}, s(state, board: board)} end {:handled, gen_tuple} # delay for wich we already have an erlang timer running. # --beware--, the tq_ref is pinned but in "when" clause we are # checking erl_tref {:delay, ^tq_tref, _delay} when is_reference(erl_tref) -> :unhandled {:delay, new_tq_ref, delay} -> # We will cancel the current timer as the next timequeue timer is not # the previous known one. We can do it asynchronoulsy (leaving a chance # for it to trigger if it would tick just now) because if we receive it, # we will have a new timer ref in the state so we will ignore it anyway. # Also we set info to false so we do not receive a cancellation message if is_reference(erl_tref) do :erlang.cancel_timer(erl_tref, async: true, info: false) end # our message will just be to run the lifecycle. IO.puts("delay: #{inspect(delay)}") new_erl_tref = :erlang.start_timer(delay, self(), :run_lifecycle) {:handled, {:noreply, s(state, tref: {new_tq_ref, new_erl_tref}), @lifecycle}} # now we have a new state, so we must tell the lifecycle handler that # we handled something. we will just loop on the lifecycle :empty -> :unhandled end end end