From 407bf5159176d27a601637f7230f1af713bd0dab Mon Sep 17 00:00:00 2001 From: Ayiko Date: Sat, 6 Jun 2026 10:17:49 +0300 Subject: [PATCH 1/3] Rename game supervisor for domain clarity --- lib/connect_four/application.ex | 2 +- ...namic_supervisor.ex => game_supervisor.ex} | 12 +++++----- lib/connect_four/init.ex | 10 +++++---- ...isor_test.exs => game_supervisor_test.exs} | 22 +++++++++---------- 4 files changed, 23 insertions(+), 23 deletions(-) rename lib/connect_four/{dynamic_supervisor.ex => game_supervisor.ex} (67%) rename test/connect_four/{dynamic_supervisor_test.exs => game_supervisor_test.exs} (62%) diff --git a/lib/connect_four/application.ex b/lib/connect_four/application.ex index 5ce5930..44cd6a0 100644 --- a/lib/connect_four/application.ex +++ b/lib/connect_four/application.ex @@ -16,7 +16,7 @@ defmodule ConnectFour.Application do ConnectFour.CacheRestore, ConnectFour.Cache, ConnectFour.Registry, - ConnectFour.DynamicSupervisor, + ConnectFour.GameSupervisor, ConnectFour.Init ] diff --git a/lib/connect_four/dynamic_supervisor.ex b/lib/connect_four/game_supervisor.ex similarity index 67% rename from lib/connect_four/dynamic_supervisor.ex rename to lib/connect_four/game_supervisor.ex index 16c5e11..9810835 100644 --- a/lib/connect_four/dynamic_supervisor.ex +++ b/lib/connect_four/game_supervisor.ex @@ -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() @@ -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 diff --git a/lib/connect_four/init.ex b/lib/connect_four/init.ex index 1bc76be..0285041 100644 --- a/lib/connect_four/init.ex +++ b/lib/connect_four/init.ex @@ -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 diff --git a/test/connect_four/dynamic_supervisor_test.exs b/test/connect_four/game_supervisor_test.exs similarity index 62% rename from test/connect_four/dynamic_supervisor_test.exs rename to test/connect_four/game_supervisor_test.exs index bb2e323..d11a7dc 100644 --- a/test/connect_four/dynamic_supervisor_test.exs +++ b/test/connect_four/game_supervisor_test.exs @@ -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 @@ -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) @@ -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) @@ -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") From 006c585d5f60fbdb0e69b2390c25ed4de4907ffa Mon Sep 17 00:00:00 2001 From: Ayiko Date: Sat, 6 Jun 2026 10:24:34 +0300 Subject: [PATCH 2/3] Drop tokens by column at the game boundary --- lib/connect_four/game.ex | 14 +++++++------- test/connect_four/game_test.exs | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/connect_four/game.ex b/lib/connect_four/game.ex index 469fb8b..040bb66 100644 --- a/lib/connect_four/game.ex +++ b/lib/connect_four/game.ex @@ -34,12 +34,12 @@ 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 @impl true @@ -80,9 +80,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 diff --git a/test/connect_four/game_test.exs b/test/connect_four/game_test.exs index 14b792f..60f5dd6 100644 --- a/test/connect_four/game_test.exs +++ b/test/connect_four/game_test.exs @@ -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 @@ -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)) @@ -64,11 +64,11 @@ 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") @@ -76,7 +76,7 @@ defmodule ConnectFour.GameTest do _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 From 3ebcd74be2ed1ff7d636d488989b4947a8feecdd Mon Sep 17 00:00:00 2001 From: Ayiko Date: Sat, 6 Jun 2026 10:24:51 +0300 Subject: [PATCH 3/3] Expose public ConnectFour game API --- README.md | 18 ++++++++++ lib/connect_four.ex | 71 ++++++++++++++++++++++++++++++++++---- lib/connect_four/game.ex | 20 ++++++++++- test/connect_four_test.exs | 23 ++++++++++-- 4 files changed, 122 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 0ee9ee3..aac166b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/connect_four.ex b/lib/connect_four.ex index e219bb7..9073a93 100644 --- a/lib/connect_four.ex +++ b/lib/connect_four.ex @@ -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 diff --git a/lib/connect_four/game.ex b/lib/connect_four/game.ex index 040bb66..1d1e7a6 100644 --- a/lib/connect_four/game.ex +++ b/lib/connect_four/game.ex @@ -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 @@ -42,6 +48,14 @@ defmodule ConnectFour.Game 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 def init(params) do {:ok, params, {:continue, :upsert_to_cache}} @@ -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") diff --git a/test/connect_four_test.exs b/test/connect_four_test.exs index 25d345f..8492eaf 100644 --- a/test/connect_four_test.exs +++ b/test/connect_four_test.exs @@ -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