diff --git a/lib/twenty_forty_eight/game/board.ex b/lib/twenty_forty_eight/game/board.ex index 9bff9ec..8515c48 100644 --- a/lib/twenty_forty_eight/game/board.ex +++ b/lib/twenty_forty_eight/game/board.ex @@ -98,15 +98,16 @@ defmodule TwentyFortyEight.Game.Board do # For each row, we run a two-pointer algorithm where: # # * Pointer #1 iterates through the row. - # * Pointer #2 points to the latest non-empty, non-modified cell behind pointer #1. + # * Pointer #2 points to the latest non-empty, non-modified cell behind + # pointer #1. # # As #1 iterates, if its current cell is not empty and: # - # * Has the same value as the cell of #2: the cell of #1 will be merged into that of #2 (the #2 cell - # value will be doubled and the #1 cell will be emptied) and the #2 pointer will - # be nullified. Or; - # * Does not have the same value as the cell of #2: the #2 pointer - # is updated to point to #1 before #1 continues its iteration. + # * Has the same value as the cell of #2: the cell of #1 will be merged into + # that of #2 (the #2 cell value will be doubled and the #1 cell will be + # emptied) and the #2 pointer will be nullified. Or; + # * Does not have the same value as the cell of #2: the #2 pointer is + # updated to point to #1 before #1 continues its iteration. updates = rows_for_move(board, move) |> Enum.map(fn row -> diff --git a/lib/twenty_forty_eight/game/game.ex b/lib/twenty_forty_eight/game/game.ex index fe4b3b1..aee713e 100644 --- a/lib/twenty_forty_eight/game/game.ex +++ b/lib/twenty_forty_eight/game/game.ex @@ -24,7 +24,7 @@ defmodule TwentyFortyEight.Game.Game do timestamps() end - def changeset(attrs) do + def create_changeset(attrs) do %__MODULE__{} |> cast(attrs, [ :num_rows, @@ -42,6 +42,12 @@ defmodule TwentyFortyEight.Game.Game do |> validate_inclusion(:winning_number, [1024, 2048]) end + def update_changeset(%__MODULE__{} = game, attrs) do + game + |> cast(attrs, [:board, :score, :turns, :state]) + |> validate_required([:board, :score, :turns, :state]) + end + def insert(changeset) do changeset |> cast(%{slug: generate_slug()}, [:slug]) @@ -53,6 +59,12 @@ defmodule TwentyFortyEight.Game.Game do Repo.get_by(__MODULE__, slug: slug) end + def update(%__MODULE__{} = game, attrs) do + game + |> update_changeset(attrs) + |> Repo.update() + end + defp generate_slug() do 1..@slug_length |> Enum.map_join("", fn _ -> Enum.random(@slug_alphabet) end) diff --git a/lib/twenty_forty_eight/game/manager.ex b/lib/twenty_forty_eight/game/manager.ex index 8b856fa..f7771b2 100644 --- a/lib/twenty_forty_eight/game/manager.ex +++ b/lib/twenty_forty_eight/game/manager.ex @@ -5,53 +5,121 @@ defmodule TwentyFortyEight.Game.Manager do @registry TwentyFortyEight.Game.Registry @supervisor TwentyFortyEight.Game.Supervisor + # Shutdown the server after 10 minutes to avoid dangling processes. + @timeout 10 * 60 * 1_000 - def get_game(name, %Game{} = game) when is_binary(name) do + @doc """ + Ensure a manager is running for the game named `name`. + + If no game is currently running a new one is started with state loaded from + `state`. + """ + def start(name) when is_binary(name) do case Registry.lookup(@registry, name) do [{pid, _value}] -> {:ok, pid} - [] -> DynamicSupervisor.start_child(@supervisor, {__MODULE__, {name, game}}) + [] -> DynamicSupervisor.start_child(@supervisor, {__MODULE__, name}) end end + @doc """ + Increment the game by one move. + """ def tick(name, move) do GenServer.call(via_tuple(name), {:tick, move}) end + @doc """ + Return state data suitable for updating an external store. + + This state is not sufficient for restarting a game, but is intended by + updating pre-existing state stored elsewhere (e.g. in a `%Game{}`). + """ def state(name) do GenServer.call(via_tuple(name), :state) end - def start_link({name, game}) do - GenServer.start_link(__MODULE__, game, name: via_tuple(name)) + def start_link(name) do + GenServer.start_link(__MODULE__, name, name: via_tuple(name)) end @impl true - def init(%Game{} = game) do + def init(name) do Process.flag(:trap_exit, true) - {:ok, create_or_restore_engine(game)} + game_state = Game.get_by_slug(name) + engine = create_or_restore_engine(game_state) + + state = %{ + game: game_state, + engine: engine + } + + {:ok, state, @timeout} end @impl true - def handle_call({:tick, move}, _from, game) do - {:reply, :ok, Engine.tick(game, move)} + def handle_call({:tick, move}, _from, state) do + state = %{state | engine: Engine.tick(state.engine, move)} + {:reply, :ok, state, @timeout} end @impl true - def handle_call(:state, _from, game) do - response = Map.take(game, [:board, :score, :turns, :state]) - {:reply, response, game} + def handle_call(:state, _from, state) do + {:reply, mutable_state(state), state, @timeout} end @impl true - def handle_info({:EXIT, from, reason}, game) do - IO.puts("Trapped exit from #{inspect(from)} because #{inspect(reason)}") - {:stop, reason, game} + def handle_info(:timeout, state) do + handle_exit(state) + {:stop, :shutdown, state} + end + + @impl true + def handle_info({:EXIT, _from, reason}, state) do + handle_exit(state) + {:stop, reason, state, @timeout} + end + + defp handle_exit(%{game: game} = state) do + {:ok, _} = persist_state(game, mutable_state(state)) end defp via_tuple(name) do {:via, Registry, {@registry, name}} end + defp mutable_state(%{engine: engine} = _state) do + Map.take(engine, [:board, :score, :turns, :state]) + end + + defp persist_state(game, state) do + Game.update(game, %{state | board: encode_board(state.board)}) + end + + defp encode_board(%Board{cells: cells} = board) do + # Encode the cells for JSON serialisation. + # Note that we omit the dimensions as these are already stored on the Game. + cell_values = for row <- 1..board.num_rows, col <- 1..board.num_cols, do: cells[{row, col}] + %{cells: cell_values} + end + + defp decode_board(%Game{board: %{"cells" => cell_values}} = game) do + # Decode the cells from JSON serialisation as a Board. + coordinates = for row <- 1..game.num_rows, col <- 1..game.num_cols, do: {row, col} + + cell_values = + Enum.map(cell_values, fn + nil -> nil + "obstacle" -> :obstacle + value when is_integer(value) -> value + end) + + %Board{ + cells: Enum.zip(coordinates, cell_values) |> Enum.into(%{}), + num_rows: game.num_rows, + num_cols: game.num_cols + } + end + defp create_or_restore_engine(%Game{state: :new, board: nil} = game) do board = Board.init(game.num_rows, game.num_cols, game.starting_number, game.num_obstacles) opts = [turn_start_number: game.turn_start_number, winning_number: game.winning_number] @@ -60,10 +128,8 @@ defmodule TwentyFortyEight.Game.Manager do end defp create_or_restore_engine(game) do - board = %Board{cells: game.board, num_rows: game.num_rows, num_cols: game.num_cols} - state = %{ - board: board, + board: decode_board(game), score: game.score, turns: game.turns, state: game.state, diff --git a/lib/twenty_forty_eight_web/controllers/game_controller.ex b/lib/twenty_forty_eight_web/controllers/game_controller.ex index f13acc6..806f899 100644 --- a/lib/twenty_forty_eight_web/controllers/game_controller.ex +++ b/lib/twenty_forty_eight_web/controllers/game_controller.ex @@ -4,12 +4,12 @@ defmodule TwentyFortyEightWeb.GameController do alias TwentyFortyEight.Game.Game def new(conn, _params) do - changeset = Game.changeset(%{}) + changeset = Game.create_changeset(%{}) render(conn, :new, changeset: changeset) end def create(conn, %{"game" => attrs} = _params) do - changeset = Game.changeset(attrs) + changeset = Game.create_changeset(attrs) case Game.insert(changeset) do {:ok, game} -> redirect(conn, to: ~p"/#{game.slug}") diff --git a/lib/twenty_forty_eight_web/live/game_live.ex b/lib/twenty_forty_eight_web/live/game_live.ex index d00f027..96df54c 100644 --- a/lib/twenty_forty_eight_web/live/game_live.ex +++ b/lib/twenty_forty_eight_web/live/game_live.ex @@ -33,9 +33,8 @@ defmodule TwentyFortyEightWeb.GameLive do |> put_flash(:error, "Could not find game with ID #{name}.") |> redirect(to: ~p"/")} - game -> - # TODO change game to die after no interaction (or put similar logic in the manager?) - {:ok, _pid} = GameManager.get_game(name, game) + _game_state -> + {:ok, _pid} = GameManager.start(name) if connected?(socket) do Phoenix.PubSub.subscribe(TwentyFortyEight.PubSub, name)