Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,21 @@ cd connect_four
mix compile
iex -S mix
```

## Public API

Use `ConnectFour` as the application boundary:

```elixir
{:ok, _pid} = ConnectFour.create_game("game-1", "Player 1")
:ok = ConnectFour.join_game("game-1", "Player 2")
:no_win = ConnectFour.drop_token("game-1", :player1, 3)
state = ConnectFour.get_state("game-1")
:ok = ConnectFour.stop_game("game-1")
```

## Runtime State

The ETS cache is just for fast runtime access while the application is running. Durable
recovery across deploys should come from a database or event log, with
`ConnectFour.Init` rehydrating active games on boot.
71 changes: 64 additions & 7 deletions lib/connect_four.ex
Original file line number Diff line number Diff line change
@@ -1,18 +1,75 @@
defmodule ConnectFour do
@moduledoc """
Documentation for `ConnectFour`.
Public API for creating and playing Connect Four games.

## Examples

iex> {:ok, _apps} = Application.ensure_all_started(:connect_four)
iex> game_id = "public-api-doctest"
iex> {:ok, pid} = ConnectFour.create_game(game_id, "Player 1")
iex> Process.alive?(pid)
true
iex> ConnectFour.join_game(game_id, "Player 2")
:ok
iex> ConnectFour.drop_token(game_id, :player1, 3)
:no_win
iex> state = ConnectFour.get_state(game_id)
iex> state.player1.name
"Player 1"
iex> state.player2.name
"Player 2"
iex> state.board |> Enum.at(5) |> Enum.at(3)
:player1
iex> ConnectFour.stop_game(game_id)
:ok
"""

alias ConnectFour.Game
alias ConnectFour.GameSupervisor

@type game_id :: binary()
@type player :: :player1 | :player2

@doc """
Hello world.
Create a new game with the first player.
"""
@spec create_game(game_id(), binary()) :: Supervisor.on_start_child()
def create_game(game_id, player_name)
when is_binary(game_id) and is_binary(player_name) do
GameSupervisor.spawn_game(game_id, name: player_name)
end

## Examples
@doc """
Join an existing game as the second player.
"""
@spec join_game(game_id(), binary()) :: :ok | :error
def join_game(game_id, player_name)
when is_binary(game_id) and is_binary(player_name) do
Game.add_player(game_id, player_name)
end

iex> ConnectFour.hello()
:world
@doc """
Drop a token into a column.
"""
@spec drop_token(game_id(), player(), non_neg_integer()) ::
ConnectFour.Board.status() | :error | {:error, atom()}
def drop_token(game_id, player, column) do
Game.drop_token(game_id, player, column)
end

@doc """
Return the current game state.
"""
@spec get_state(game_id()) :: Game.state()
def get_state(game_id) when is_binary(game_id) do
Game.get_state(game_id)
end

@doc """
Stop a running game and remove it from the runtime cache.
"""
def hello do
:world
@spec stop_game(game_id()) :: :ok
def stop_game(game_id) when is_binary(game_id) do
GameSupervisor.stop_game(game_id)
end
end
2 changes: 1 addition & 1 deletion lib/connect_four/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ defmodule ConnectFour.Application do
ConnectFour.CacheRestore,
ConnectFour.Cache,
ConnectFour.Registry,
ConnectFour.DynamicSupervisor,
ConnectFour.GameSupervisor,
ConnectFour.Init
]

Expand Down
34 changes: 26 additions & 8 deletions lib/connect_four/game.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ defmodule ConnectFour.Game do

@players [:player1, :player2]

@type state :: %{board: Board.t(), rules: Rules.t(), player1: map(), player2: map()}
@type state :: %{
id: binary(),
board: Board.t(),
rules: Rules.t(),
player1: map(),
player2: map()
}

@doc """
Start a game and register it with the given name in the registry
Expand All @@ -34,12 +40,20 @@ defmodule ConnectFour.Game do
end

@doc """
Drop a token in the given row and column
Drop a token into the given column.
"""
@spec drop_token(binary(), atom(), non_neg_integer(), non_neg_integer()) ::
:ok | :error | {:error, atom()}
def drop_token(game, player, row, col) when player in @players and is_integer(col) do
GenServer.call(via_tuple(game), {:drop_token, player, row, col})
@spec drop_token(binary(), atom(), non_neg_integer()) ::
Board.status() | :error | {:error, atom()}
def drop_token(game, player, col) when player in @players and is_integer(col) do
GenServer.call(via_tuple(game), {:drop_token, player, col})
end

@doc """
Return the current game state.
"""
@spec get_state(binary()) :: state()
def get_state(game) do
GenServer.call(via_tuple(game), :get_state)
end

@impl true
Expand Down Expand Up @@ -80,9 +94,9 @@ defmodule ConnectFour.Game do
end
end

def handle_call({:drop_token, player, row, col}, _from, state) do
def handle_call({:drop_token, player, col}, _from, state) do
with {:ok, rules} <- Rules.check(state.rules, {:drop_token, player}),
{:ok, cell} <- Cell.new(row, col),
{:ok, cell} <- Cell.new(0, col),
{:ok, _actual_cell, win_status, board} <- Board.drop(state.board, cell, player),
{:ok, rules} <- Rules.check(rules, {:win_check, win_status}) do
state
Expand All @@ -95,6 +109,10 @@ defmodule ConnectFour.Game do
end
end

def handle_call(:get_state, _from, state) do
{:reply, state, state, @timeout}
end

@impl true
def handle_info(:timeout, state) do
Logger.debug("A game has timed out")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
defmodule ConnectFour.DynamicSupervisor do
defmodule ConnectFour.GameSupervisor do
use DynamicSupervisor

require Logger

alias ConnectFour.Cache

@spec start_link(keyword()) :: Supervisor.on_start()
Expand All @@ -15,15 +13,15 @@ defmodule ConnectFour.DynamicSupervisor do
DynamicSupervisor.init(strategy: :one_for_one)
end

@spec spawn_game(binary(), map()) :: {:ok, pid()}
def spawn_game(id, params) do
@spec spawn_game(binary(), keyword()) :: Supervisor.on_start_child()
def spawn_game(id, opts) when is_binary(id) and is_list(opts) do
child_spec = %{
id: ConnectFour.Game,
start: {ConnectFour.Game, :start_link, [id, params]},
start: {ConnectFour.Game, :start_link, [id, opts]},
restart: :transient
}

{:ok, _pid} = DynamicSupervisor.start_child(__MODULE__, child_spec)
DynamicSupervisor.start_child(__MODULE__, child_spec)
end

@spec stop_game(binary()) :: :ok
Expand Down
10 changes: 6 additions & 4 deletions lib/connect_four/init.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ defmodule ConnectFour.Init do
end

defp start_game_processes do
## If we had a database, we'd fetch all active games from it and start a game process for
## each one. For now, we just start with an empty list.
## ETS is only a runtime cache. Durable recovery after a deploy should come
## from a database or event log, then active games can be rehydrated here.
[]
|> Enum.each(fn %{id: id} = game_params ->
ConnectFour.DynamicSupervisor.spawn_game(id, game_params)
|> Enum.each(fn game_opts ->
id = Keyword.fetch!(game_opts, :id)

ConnectFour.GameSupervisor.spawn_game(id, Keyword.delete(game_opts, :id))
end)
end
end
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
defmodule ConnectFour.DynamicSupervisorTest do
defmodule ConnectFour.GameSupervisorTest do
use ExUnit.Case, async: true

describe "ConnectFour.DynamicSupervisor" do
describe "ConnectFour.GameSupervisor" do
setup do
{:ok, _} = Application.ensure_all_started(:connect_four)
:ok
end

test "starts the supervisor" do
assert Process.alive?(Process.whereis(ConnectFour.DynamicSupervisor))
assert Process.alive?(Process.whereis(ConnectFour.GameSupervisor))
end

test "spawns a game process" do
{:ok, pid} = ConnectFour.DynamicSupervisor.spawn_game("game_1", name: "Player1")
{:ok, pid} = ConnectFour.GameSupervisor.spawn_game("game_1", name: "Player1")
assert Process.alive?(pid)
end

test "supervisor tracks spawned games" do
{:ok, pid} = ConnectFour.DynamicSupervisor.spawn_game("game_2", name: "Player1")
children = DynamicSupervisor.which_children(ConnectFour.DynamicSupervisor)
{:ok, pid} = ConnectFour.GameSupervisor.spawn_game("game_2", name: "Player1")
children = DynamicSupervisor.which_children(ConnectFour.GameSupervisor)

assert Enum.any?(children, fn
{_, child_pid, :worker, _} -> child_pid == pid
Expand All @@ -27,11 +27,11 @@ defmodule ConnectFour.DynamicSupervisorTest do
end

test ":transient strategy does not restart child on normal exit" do
{:ok, pid} = ConnectFour.DynamicSupervisor.spawn_game("game_3", name: "Player3")
{:ok, pid} = ConnectFour.GameSupervisor.spawn_game("game_3", name: "Player3")

assert {:ok, ^pid} = ConnectFour.Registry.lookup_game("game_3")

DynamicSupervisor.terminate_child(ConnectFour.DynamicSupervisor, pid)
DynamicSupervisor.terminate_child(ConnectFour.GameSupervisor, pid)

Process.sleep(100)

Expand All @@ -41,7 +41,7 @@ defmodule ConnectFour.DynamicSupervisorTest do
end

test ":transient strategy restarts child on abnormal exit with" do
{:ok, pid} = ConnectFour.DynamicSupervisor.spawn_game("game_4", name: "Player4")
{:ok, pid} = ConnectFour.GameSupervisor.spawn_game("game_4", name: "Player4")
assert {:ok, ^pid} = ConnectFour.Registry.lookup_game("game_4")

assert Process.alive?(pid)
Expand All @@ -58,9 +58,9 @@ defmodule ConnectFour.DynamicSupervisorTest do
end

test "stop_game/1 stops the game process" do
{:ok, pid} = ConnectFour.DynamicSupervisor.spawn_game("game_5", name: "Player5")
{:ok, pid} = ConnectFour.GameSupervisor.spawn_game("game_5", name: "Player5")
assert Process.alive?(pid)
ConnectFour.DynamicSupervisor.stop_game("game_5")
ConnectFour.GameSupervisor.stop_game("game_5")
Process.sleep(100)
refute Process.alive?(pid)
assert {:error, :not_found} = ConnectFour.Registry.lookup_game("game_5")
Expand Down
14 changes: 7 additions & 7 deletions test/connect_four/game_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ defmodule ConnectFour.GameTest do
test "state change first requires adding a player after initialization" do
game_name = "test_game2"
{:ok, _game_pid} = Game.start_link(game_name, name: "Player1")
assert :error = Game.drop_token(game_name, :player1, 0, 3)
assert :error = Game.drop_token(game_name, :player2, 0, 5)
assert :error = Game.drop_token(game_name, :player1, 3)
assert :error = Game.drop_token(game_name, :player2, 5)
end

test "add_player/2: adding a player works correctly" do
Expand All @@ -52,7 +52,7 @@ defmodule ConnectFour.GameTest do
:ok = Game.add_player(game_name, "Player2")
state1 = :sys.get_state(via_tuple(game_name))

assert :no_win = Game.drop_token(game_name, :player1, 0, 0)
assert :no_win = Game.drop_token(game_name, :player1, 0)

state2 = :sys.get_state(via_tuple(game_name))

Expand All @@ -64,19 +64,19 @@ defmodule ConnectFour.GameTest do
{:ok, _game_pid} = Game.start_link(game_name, name: "Player1")
:ok = Game.add_player(game_name, "Player2")

assert {:error, :invalid_cell} = Game.drop_token(game_name, :player1, -1, 0)
assert {:error, :invalid_cell} = Game.drop_token(game_name, :player1, 0, 7)
assert {:error, :invalid_cell} = Game.drop_token(game_name, :player1, -1)
assert {:error, :invalid_cell} = Game.drop_token(game_name, :player1, 7)
end

test "drop_token/4: with full column returns an error" do
test "drop_token/3: with full column returns an error" do
game_name = "test_game7"
{:ok, _game_pid} = Game.start_link(game_name, name: "Player1")
:ok = Game.add_player(game_name, "Player2")
board = List.duplicate(:player2, 7) |> List.duplicate(6)

_new_state = :sys.replace_state(via_tuple(game_name), fn state -> %{state | board: board} end)

assert {:error, :column_full} = Game.drop_token(game_name, :player1, 0, 0)
assert {:error, :column_full} = Game.drop_token(game_name, :player1, 0)
end

test "handles game :timeout message correctly" do
Expand Down
23 changes: 21 additions & 2 deletions test/connect_four_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,26 @@ defmodule ConnectFourTest do
use ExUnit.Case
doctest ConnectFour

test "greets the world" do
assert ConnectFour.hello() == :world
setup do
{:ok, _} = Application.ensure_all_started(:connect_four)
:ok
end

test "creates, joins, plays, reads, and stops a game through the public API" do
game_id = "public_api_#{System.unique_integer([:positive])}"

assert {:ok, pid} = ConnectFour.create_game(game_id, "Player1")
assert Process.alive?(pid)

assert %{player1: %{name: "Player1"}, player2: %{name: nil}} =
ConnectFour.get_state(game_id)

assert :ok = ConnectFour.join_game(game_id, "Player2")
assert :no_win = ConnectFour.drop_token(game_id, :player1, 0)

assert %{board: board, player2: %{name: "Player2"}} = ConnectFour.get_state(game_id)
assert :player1 = board |> Enum.at(5) |> Enum.at(0)

assert :ok = ConnectFour.stop_game(game_id)
end
end
Loading