Skip to content

Commit

Permalink
Checkpoint.
Browse files Browse the repository at this point in the history
  • Loading branch information
alexpearce committed Oct 30, 2023
1 parent 1f229c4 commit e20a052
Show file tree
Hide file tree
Showing 5 changed files with 107 additions and 29 deletions.
13 changes: 7 additions & 6 deletions lib/twenty_forty_eight/game/board.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
14 changes: 13 additions & 1 deletion lib/twenty_forty_eight/game/game.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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])
Expand All @@ -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)
Expand Down
100 changes: 83 additions & 17 deletions lib/twenty_forty_eight/game/manager.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions lib/twenty_forty_eight_web/controllers/game_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
5 changes: 2 additions & 3 deletions lib/twenty_forty_eight_web/live/game_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit e20a052

Please sign in to comment.