From 89c16acc18bf13f044f49871dbaa6aacc8425634 Mon Sep 17 00:00:00 2001 From: Rowa Date: Sat, 25 May 2024 22:00:58 +0300 Subject: [PATCH 01/10] birth battleship --- .gitignore | 2 +- README.md | 10 +- lib/{islands_engine.ex => battle_ship.ex} | 6 +- .../application.ex | 8 +- lib/battle_ship/board.ex | 111 ++++++++++++++++++ .../coordinate.ex | 2 +- lib/battle_ship/demo_proc.ex | 9 ++ lib/{islands_engine => battle_ship}/game.ex | 48 ++++---- .../guesses.ex | 9 +- lib/{islands_engine => battle_ship}/rules.ex | 40 +++---- .../island.ex => battle_ship/ship.ex} | 50 +++++--- lib/islands_engine/board.ex | 107 ----------------- mix.exs | 6 +- test/battle_ship_test.exs | 8 ++ test/islands_engine_test.exs | 8 -- 15 files changed, 228 insertions(+), 196 deletions(-) rename lib/{islands_engine.ex => battle_ship.ex} (55%) rename lib/{islands_engine => battle_ship}/application.ex (65%) create mode 100644 lib/battle_ship/board.ex rename lib/{islands_engine => battle_ship}/coordinate.ex (92%) create mode 100644 lib/battle_ship/demo_proc.ex rename lib/{islands_engine => battle_ship}/game.ex (74%) rename lib/{islands_engine => battle_ship}/guesses.ex (71%) rename lib/{islands_engine => battle_ship}/rules.ex (63%) rename lib/{islands_engine/island.ex => battle_ship/ship.ex} (50%) delete mode 100644 lib/islands_engine/board.ex create mode 100644 test/battle_ship_test.exs delete mode 100644 test/islands_engine_test.exs diff --git a/.gitignore b/.gitignore index 7e8ae05..0556168 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,7 @@ erl_crash.dump *.ez # Ignore package tarball (built via "mix hex.build"). -islands_engine-*.tar +battle_ship-*.tar # Temporary files, for example, from tests. /tmp/ diff --git a/README.md b/README.md index ac61145..6ca6c4d 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,26 @@ -# IslandsEngine +# BattleShip ![Game Design](images/image.png) **Game Description** -The coordinates that make up the islands are the color of sand. When the player’s opponent guesses correctly and hits an island, the coordinate the opponent hits will turn green. Otherwise, it will turn black. If all the coordinates that make up an island are hit, the island is forested, and when all of a player’s islands are forested, the opponent has won the game. +The coordinates that make up the ships are the color of sand. When the player’s opponent guesses correctly and hits a ship, the coordinate the opponent hits will turn red. Otherwise, it will turn black. If all the coordinates that make up a ship are hit, the ship is sunk, and when all of a player’s ships are sunk, the opponent has won the game. **TODO: Add demo video** ## Installation If [available in Hex](https://hex.pm/docs/publish), the package can be installed -by adding `islands_engine` to your list of dependencies in `mix.exs`: +by adding `battle_ship` to your list of dependencies in `mix.exs`: ```elixir def deps do [ - {:islands_engine, "~> 0.1.0"} + {:battle_ship, "~> 0.1.0"} ] end ``` Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) and published on [HexDocs](https://hexdocs.pm). Once published, the docs can -be found at . +be found at . diff --git a/lib/islands_engine.ex b/lib/battle_ship.ex similarity index 55% rename from lib/islands_engine.ex rename to lib/battle_ship.ex index 44daa94..9e4f2d9 100644 --- a/lib/islands_engine.ex +++ b/lib/battle_ship.ex @@ -1,6 +1,6 @@ -defmodule IslandsEngine do +defmodule BattleShip do @moduledoc """ - Documentation for `IslandsEngine`. + Documentation for `BattleShip`. """ @doc """ @@ -8,7 +8,7 @@ defmodule IslandsEngine do ## Examples - iex> IslandsEngine.hello() + iex> BattleShip.hello() :world """ diff --git a/lib/islands_engine/application.ex b/lib/battle_ship/application.ex similarity index 65% rename from lib/islands_engine/application.ex rename to lib/battle_ship/application.ex index 47e74bd..d6884cf 100644 --- a/lib/islands_engine/application.ex +++ b/lib/battle_ship/application.ex @@ -1,4 +1,4 @@ -defmodule IslandsEngine.Application do +defmodule BattleShip.Application do # See https://hexdocs.pm/elixir/Application.html # for more information on OTP Applications @moduledoc false @@ -8,14 +8,14 @@ defmodule IslandsEngine.Application do @impl true def start(_type, _args) do children = [ - # Starts a worker by calling: IslandsEngine.Worker.start_link(arg) - # {IslandsEngine.Worker, arg} + # Starts a worker by calling: BattleShip.Worker.start_link(arg) + # {BattleShip.Worker, arg} {Registry, keys: :unique, name: Registry.Game} ] # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options - opts = [strategy: :one_for_one, name: IslandsEngine.Supervisor] + opts = [strategy: :one_for_one, name: BattleShip.Supervisor] Supervisor.start_link(children, opts) end end diff --git a/lib/battle_ship/board.ex b/lib/battle_ship/board.ex new file mode 100644 index 0000000..1b1ab94 --- /dev/null +++ b/lib/battle_ship/board.ex @@ -0,0 +1,111 @@ +defmodule BattleShip.Board do + @moduledoc false + + alias BattleShip.{Coordinate, Ship} + + @doc """ + Gives a game board. + + Returns `%{}`. + + ## Examples + + iex> BattleShip.Board.new() + %{} + + """ + def new(), do: %{} + + @spec position_ship(map(), atom(), struct()) :: {:error, :overlapping_ship} | map() + @doc """ + This function takes a game board, a key, and a ship to be positioned on the board. + It checks if the new ship overlaps with any existing ships on the board. If there + is an overlap, it returns `{:error, :overlapping_ship}`. Otherwise, it updates the + board by adding the new ship at the specified key and returns the updated board. + + ## Examples + + """ + def position_ship(board, key, %Ship{} = ship) do + case overlaps_existing_ship?(board, key, ship) do + true -> + {:error, :overlapping_ship} + + false -> + Map.put(board, key, ship) + end + end + + defp overlaps_existing_ship?(board, new_key, new_ship) do + Enum.any?(board, fn {key, ship} -> + key != new_key and Ship.overlaps?(ship, new_ship) + end) + end + + @spec all_ships_positioned?(map()) :: boolean() + def all_ships_positioned?(board), do: Enum.all?(Ship.types(), &Map.has_key?(board, &1)) + + @spec guess(map(), %Coordinate{}) :: + {:hit, boolean(), :no_win | :win, map()} | {:miss, :none, :no_win, map()} + @doc """ + whether the guess was a hit or a miss, either :none or the type of ship that was sunk, :win or :no_win, and finally the board map itself + + Returns ``. + + ## Examples + + iex> MyApp.Hello.world(:john) + :ok + + """ + def guess(board, %Coordinate{} = coordinate) do + board + |> check_all_ships(coordinate) + |> guess_response(board) + end + + defp check_all_ships(board, coordinate) do + Enum.find_value(board, :miss, fn {key, ship} -> + case Ship.guess(ship, coordinate) do + {:hit, ship} -> {key, ship} + :miss -> false + end + end) + end + + defp guess_response({key, ship}, board) do + board = %{board | key => ship} + {:hit, sink_check(board, key), win_check(board), board} + end + + defp guess_response(:miss, board), do: {:miss, :none, :no_win, board} + + defp sink_check(board, key) do + case sunk?(board, key) do + true -> + key + + false -> + :none + end + end + + defp sunk?(board, key) do + board + |> Map.fetch!(key) + |> Ship.sunk?() + end + + defp win_check(board) do + case all_sunk(board) do + true -> + :win + + false -> + :no_win + end + end + + defp all_sunk(board), + do: Enum.all?(board, fn {_key, ship} -> Ship.sunk?(ship) end) +end diff --git a/lib/islands_engine/coordinate.ex b/lib/battle_ship/coordinate.ex similarity index 92% rename from lib/islands_engine/coordinate.ex rename to lib/battle_ship/coordinate.ex index c028c34..8f33528 100644 --- a/lib/islands_engine/coordinate.ex +++ b/lib/battle_ship/coordinate.ex @@ -1,4 +1,4 @@ -defmodule IslandsEngine.Coordinate do +defmodule BattleShip.Coordinate do @moduledoc """ This is the Coordinate module. It returns a tuple of atom :ok and a coordinate struct with a row and columns keys with values of between 1 and 10 """ diff --git a/lib/battle_ship/demo_proc.ex b/lib/battle_ship/demo_proc.ex new file mode 100644 index 0000000..7d6f18c --- /dev/null +++ b/lib/battle_ship/demo_proc.ex @@ -0,0 +1,9 @@ +defmodule BattleShip.DemoProc do + def loop() do + receive do + msg -> IO.puts("I received message: #{msg}") + end + + loop() + end +end diff --git a/lib/islands_engine/game.ex b/lib/battle_ship/game.ex similarity index 74% rename from lib/islands_engine/game.ex rename to lib/battle_ship/game.ex index e2be584..dc01fc4 100644 --- a/lib/islands_engine/game.ex +++ b/lib/battle_ship/game.ex @@ -1,10 +1,10 @@ -defmodule IslandEngine.Game do +defmodule BattleShip.Game do @moduledoc false use GenServer - alias IslandsEngine.{Board, Coordinate, Guesses, Island} - alias IslandEngine.Rules + alias BattleShip.{Board, Coordinate, Guesses, Ship} + alias BattleShip.Rules @players [:player1, :player2] @@ -27,9 +27,9 @@ defmodule IslandEngine.Game do %{ player1: %{board: map(), guesses: map(), name: String.t()}, player2: %{board: map(), guesses: map(), name: nil}, - rules: %IslandEngine.Rules{ - player1: :islands_not_set, - player2: :islands_not_set, + rules: %BattleShip.Rules{ + player1: :ships_not_set, + player2: :ships_not_set, state: :initialized } }} @@ -42,11 +42,11 @@ defmodule IslandEngine.Game do @spec add_player(pid(), binary()) :: :error | {:reply, :ok, %{}} def add_player(game, name) when is_binary(name), do: GenServer.call(game, {:add_player, name}) - def position_island(game, player, key, row, col) when player in @players, - do: GenServer.call(game, {:position_island, player, key, row, col}) + def position_ship(game, player, key, row, col) when player in @players, + do: GenServer.call(game, {:position_ship, player, key, row, col}) - def set_islands(game, player) when player in @players, - do: GenServer.call(game, {:set_islands, player}) + def set_ships(game, player) when player in @players, + do: GenServer.call(game, {:set_ships, player}) @spec guess_coordinate( pid(), @@ -63,14 +63,14 @@ defmodule IslandEngine.Game do with {:ok, rules} <- Rules.check(state_data.rules, {:guess_coordinate, player_key}), {:ok, coordinate} <- Coordinate.new(row, col), - {hit_or_miss, forested_island, win_status, opponent_board} <- + {hit_or_miss, sunk_ship, win_status, opponent_board} <- Board.guess(opponent_board, coordinate), {:ok, rules} <- Rules.check(rules, {:win_check, win_status}) do state_data |> update_board(opponent_key, opponent_board) |> update_guesses(player_key, hit_or_miss, coordinate) |> update_rules(rules) - |> reply_success({hit_or_miss, forested_island, win_status}) + |> reply_success({hit_or_miss, sunk_ship, win_status}) else :error -> {:reply, :error, state_data} @@ -80,17 +80,17 @@ defmodule IslandEngine.Game do end end - def handle_call({:set_islands, player}, _from, state_data) do + def handle_call({:set_ships, player}, _from, state_data) do board = player_board(state_data, player) - with {:ok, rules} <- Rules.check(state_data.rules, {:set_islands, player}), - true <- Board.all_islands_positioned?(board) do + with {:ok, rules} <- Rules.check(state_data.rules, {:set_ships, player}), + true <- Board.all_ships_positioned?(board) do state_data |> update_rules(rules) |> reply_success({:ok, board}) else :error -> {:reply, :error, state_data} - false -> {:reply, {:error, :not_all_islands_positioned}, state_data} + false -> {:reply, {:error, :not_all_ships_positioned}, state_data} end end @@ -105,13 +105,13 @@ defmodule IslandEngine.Game do end end - def handle_call({:position_island, player, key, row, col}, _from, state_data) do + def handle_call({:position_ship, player, key, row, col}, _from, state_data) do board = player_board(state_data, player) - with {:ok, rules} <- Rules.check(state_data.rules, {:position_islands, player}), + with {:ok, rules} <- Rules.check(state_data.rules, {:position_ships, player}), {:ok, coordinate} <- Coordinate.new(row, col), - {:ok, island} <- Island.new(key, coordinate), - %{} = board <- Board.position_island(board, key, island) do + {:ok, ship} <- Ship.new(key, coordinate), + %{} = board <- Board.position_ship(board, key, ship) do state_data |> update_board(player, board) |> update_rules(rules) @@ -123,11 +123,11 @@ defmodule IslandEngine.Game do {:error, :invalid_coordinate} -> {:reply, {:error, :invalid_coordinate}, state_data} - {:error, :invalid_island_type} -> - {:error, {:error, :invalid_island_type}, state_data} + {:error, :invalid_ship_type} -> + {:error, {:error, :invalid_ship_type}, state_data} - {:error, :overlapping_island} -> - {:error, {:error, :overlapping_island}, state_data} + {:error, :overlapping_ship} -> + {:error, {:error, :overlapping_ship}, state_data} end end diff --git a/lib/islands_engine/guesses.ex b/lib/battle_ship/guesses.ex similarity index 71% rename from lib/islands_engine/guesses.ex rename to lib/battle_ship/guesses.ex index 123a65a..f79118b 100644 --- a/lib/islands_engine/guesses.ex +++ b/lib/battle_ship/guesses.ex @@ -1,21 +1,22 @@ -defmodule IslandsEngine.Guesses do +defmodule BattleShip.Guesses do @moduledoc """ This is the Guesses module. """ - alias IslandsEngine.{Coordinate, Guesses} + alias BattleShip.{Coordinate, Guesses} @enforce_keys [:hits, :misses] defstruct [:hits, :misses] # Elixir’s MapSet data structure guarantees that each member of the MapSet will be unique to cater for the fact that a specific guess can be done more than once. A guess already made will simply be ignored by MapSet. Returns a new guesses struct. - @spec new() :: %IslandsEngine.Guesses{hits: MapSet.t(), misses: MapSet.t()} + @spec new() :: %BattleShip.Guesses{hits: MapSet.t(), misses: MapSet.t()} def new(), do: %Guesses{hits: MapSet.new(), misses: MapSet.new()} + @spec add(struct(), atom(), struct()) :: map() def add(%Guesses{} = guesses, :hit, %Coordinate{} = coordinate) do update_in(guesses.hits, &MapSet.put(&1, coordinate)) end def add(%Guesses{} = guesses, :miss, %Coordinate{} = coordinate) do - update_in(guesses.miss, &MapSet.put(&1, coordinate)) + update_in(guesses.misses, &MapSet.put(&1, coordinate)) end end diff --git a/lib/islands_engine/rules.ex b/lib/battle_ship/rules.ex similarity index 63% rename from lib/islands_engine/rules.ex rename to lib/battle_ship/rules.ex index c51a19c..d984ab9 100644 --- a/lib/islands_engine/rules.ex +++ b/lib/battle_ship/rules.ex @@ -1,16 +1,16 @@ -defmodule IslandEngine.Rules do +defmodule BattleShip.Rules do @moduledoc """ This is the Rules module. """ alias __MODULE__ defstruct state: :initialized, - player1: :islands_not_set, - player2: :islands_not_set + player1: :ships_not_set, + player2: :ships_not_set - @spec new() :: %IslandEngine.Rules{ - player1: :islands_not_set, - player2: :islands_not_set, + @spec new() :: %BattleShip.Rules{ + player1: :ships_not_set, + player2: :ships_not_set, state: :initialized } @doc """ @@ -20,11 +20,11 @@ defmodule IslandEngine.Rules do ## Examples - iex> IslandEngine.Island.new() - %IslandEngine.Rules{ + iex> BattleShip.Ship.new() + %BattleShip.Rules{ state: :initialized, - player1: :islands_not_set, - player2: :islands_not_set + player1: :ships_not_set, + player2: :ships_not_set } """ @@ -37,7 +37,7 @@ defmodule IslandEngine.Rules do ## Examples - iex> IslandEngine.Island.check(%Rules{state: :initialized} = rules, :add_player) + iex> BattleShip.Ship.check(%Rules{state: :initialized} = rules, :add_player) {:ok, %Rules{rules | state: :players_set}} """ @@ -46,18 +46,18 @@ defmodule IslandEngine.Rules do {:ok, %Rules{rules | state: :players_set}} end - def check(%Rules{state: :players_set} = rules, {:position_islands, player}) do + def check(%Rules{state: :players_set} = rules, {:position_ships, player}) do case Map.fetch!(rules, player) do - # If the value for the player key is :islands_not_set, it’s fine for that player to move her islands, so we return {:ok, rules}. If the values is :islands_set, it’s not okay for her to move her islands, so we return :error - :islands_set -> :error - :islands_not_set -> {:ok, rules} + # If the value for the player key is :ships_not_set, it’s fine for that player to move her ships, so we return {:ok, rules}. If the values is :ships_set, it’s not okay for her to move her ships, so we return :error + :ships_set -> :error + :ships_not_set -> {:ok, rules} end end - def check(%Rules{state: :players_set} = rules, {:set_islands, player}) do - rules = Map.put(rules, player, :islands_set) + def check(%Rules{state: :players_set} = rules, {:set_ships, player}) do + rules = Map.put(rules, player, :ships_set) - case both_players_islands_set?(rules) do + case both_players_ships_set?(rules) do true -> {:ok, %Rules{rules | state: :player1_turn}} @@ -88,6 +88,6 @@ defmodule IslandEngine.Rules do def check(_state, _action), do: :error - defp both_players_islands_set?(rules), - do: rules.player1 == :islands_set && rules.player2 == :islands_set + defp both_players_ships_set?(rules), + do: rules.player1 == :ships_set && rules.player2 == :ships_set end diff --git a/lib/islands_engine/island.ex b/lib/battle_ship/ship.ex similarity index 50% rename from lib/islands_engine/island.ex rename to lib/battle_ship/ship.ex index dcb53ca..e349289 100644 --- a/lib/islands_engine/island.ex +++ b/lib/battle_ship/ship.ex @@ -1,8 +1,8 @@ -defmodule IslandsEngine.Island do +defmodule BattleShip.Ship do @moduledoc """ - This is the Island module. + This is the Ship module. """ - alias IslandsEngine.{Coordinate, Island} + alias BattleShip.{Coordinate, Ship} @enforce_keys [:coordinates, :hit_coordinates] defstruct [:coordinates, :hit_coordinates] @@ -10,20 +10,21 @@ defmodule IslandsEngine.Island do @doc """ Gives a game board. - Returns `%{:ok, %IslandEngine.Island{}}`. + Returns `%{:ok, %BattleShip.Ship{}}`. ## Examples - iex> IslandEngine.Island.new() + iex> BattleShip.Ship.new() %{} """ def new(type, %Coordinate{} = upper_left) do with [_ | _] = offsets <- offset(type), %MapSet{} = coordinates <- add_coordinates(offsets, upper_left) do - {:ok, %Island{coordinates: coordinates, hit_coordinates: MapSet.new()}} + {:ok, %Ship{coordinates: coordinates, hit_coordinates: MapSet.new()}} else - error -> error + error -> + error end end @@ -32,9 +33,8 @@ defmodule IslandsEngine.Island do defp offset(:dot), do: [{0, 0}] defp offset(:l_shape), do: [{0, 0}, {1, 0}, {2, 0}, {2, 1}] defp offset(:s_shape), do: [{0, 1}, {0, 2}, {1, 0}, {1, 1}] - defp offset(_), do: [:error, :invalid_island_type] + defp offset(_), do: {:error, :invalid_ship_type} - # enumerates over the list of offsets, create a new coordinate for each one, and put them all into the same set. It takes an enumerable, a starting value for an accumulator, and a function to apply to each enumerated value. For us, those three arguments will be the list of offsets, a new MapSet, and a new function we’ll get to in a minute. must return one of two tagged tuples: either {:cont, some_value} to continue the enumeration, or {:halt, some_value} to end it defp add_coordinates(offsets, upper_left) do Enum.reduce_while(offsets, MapSet.new(), fn offset, acc -> add_coordinate(acc, upper_left, offset) @@ -51,21 +51,39 @@ defmodule IslandsEngine.Island do end end - def overlaps?(existing_island, new_island), - do: not MapSet.disjoint?(existing_island.coordinates, new_island.coordinates) + @spec overlaps?(struct(), struct()) :: boolean() + def overlaps?(existing_ship, new_ship), + do: not MapSet.disjoint?(existing_ship.coordinates, new_ship.coordinates) - def guess(island, coordinate) do - case MapSet.member?(island.coordinates, coordinate) do + @spec guess(struct(), struct()) :: + :miss + | {:hit, + %{ + :coordinates => MapSet.t(), + :hit_coordinates => MapSet.t(), + optional(any()) => any() + }} + def guess(ship, coordinate) do + case MapSet.member?(ship.coordinates, coordinate) do true -> - hit_coordinates = MapSet.put(island.hit_coordinates, coordinate) - {:hit, %{island | hit_coordinates: hit_coordinates}} + hit_coordinates = MapSet.put(ship.hit_coordinates, coordinate) + {:hit, %{ship | hit_coordinates: hit_coordinates}} false -> :miss end end - def forested?(island), do: MapSet.equal?(island.coordinates, island.hit_coordinates) + @spec sunk?( + atom() + | %{ + :coordinates => MapSet.t(), + :hit_coordinates => MapSet.t(), + optional(any()) => any() + } + ) :: boolean() + def sunk?(ship), do: MapSet.equal?(ship.coordinates, ship.hit_coordinates) + @spec types() :: [:atoll | :dot | :l_shape | :s_shape | :square] def types(), do: [:atoll, :dot, :l_shape, :s_shape, :square] end diff --git a/lib/islands_engine/board.ex b/lib/islands_engine/board.ex deleted file mode 100644 index f628515..0000000 --- a/lib/islands_engine/board.ex +++ /dev/null @@ -1,107 +0,0 @@ -defmodule IslandsEngine.Board do - @moduledoc false - - alias IslandsEngine.{Coordinate, Island} - - @doc """ - Gives a game board. - - Returns `%{}`. - - ## Examples - - iex> IslandEngine.Board.new() - %{} - - """ - def new(), do: %{} - - @doc """ - This function takes a game board, a key, and an island to be positioned on the board. - It checks if the new island overlaps with any existing islands on the board. If there - is an overlap, it returns `{:error, :overlapping_island}`. Otherwise, it updates the - board by adding the new island at the specified key and returns the updated board. The front end of the application passes down an atom key representing the type of the island, as well as the row and column of the starting coordinate - - ## Examples - - """ - def position_island(board, key, %Island{} = island) do - case overlaps_existing_island?(board, key, island) do - true -> - {:error, :overlapping_island} - - false -> - Map.put(board, key, island) - end - end - - defp overlaps_existing_island?(board, new_key, new_island) do - Enum.any?(board, fn {key, island} -> - key != new_key and Island.overlaps?(island, new_island) - end) - end - - def all_islands_positioned?(board), do: Enum.all?(Island.types(), &Map.has_key?(board, &1)) - - @doc """ - whether the guess was a hit or a miss, either :none or the type of island that was forested, :win or :no_win, and finally the board map itself - - Returns ``. - - ## Examples - - iex> MyApp.Hello.world(:john) - :ok - - """ - def guess(board, %Coordinate{} = coordinate) do - board - |> check_all_islands(coordinate) - |> guess_response(board) - end - - defp check_all_islands(board, coordinate) do - Enum.find_value(board, :miss, fn {key, island} -> - case Island.guess(island, coordinate) do - {:hit, island} -> {key, island} - :miss -> false - end - end) - end - - defp guess_response({key, island}, board) do - board = %{board | key => island} - {:hit, forest_check(board, key), win_check(board), board} - end - - defp guess_response(:miss, board), do: {:miss, :none, :no_win, board} - - defp forest_check(board, key) do - case forested?(board, key) do - true -> - key - - false -> - :none - end - end - - defp forested?(board, key) do - board - |> Map.fetch!(key) - |> Island.forested?() - end - - defp win_check(board) do - case all_forested(board) do - true -> - :win - - false -> - :no_win - end - end - - defp all_forested(board), - do: Enum.all?(board, fn {_key, island} -> Island.forested?(island) end) -end diff --git a/mix.exs b/mix.exs index 849b990..28aaa5f 100644 --- a/mix.exs +++ b/mix.exs @@ -1,9 +1,9 @@ -defmodule IslandsEngine.MixProject do +defmodule BattleShip.MixProject do use Mix.Project def project do [ - app: :islands_engine, + app: :battle_ship, version: "0.1.0", elixir: "~> 1.15", start_permanent: Mix.env() == :prod, @@ -15,7 +15,7 @@ defmodule IslandsEngine.MixProject do def application do [ extra_applications: [:logger], - mod: {IslandsEngine.Application, []} + mod: {BattleShip.Application, []} ] end diff --git a/test/battle_ship_test.exs b/test/battle_ship_test.exs new file mode 100644 index 0000000..2b37a53 --- /dev/null +++ b/test/battle_ship_test.exs @@ -0,0 +1,8 @@ +defmodule BattleShipTest do + use ExUnit.Case + doctest BattleShip + + test "greets the world" do + assert BattleShip.hello() == :world + end +end diff --git a/test/islands_engine_test.exs b/test/islands_engine_test.exs deleted file mode 100644 index 8fcd537..0000000 --- a/test/islands_engine_test.exs +++ /dev/null @@ -1,8 +0,0 @@ -defmodule IslandsEngineTest do - use ExUnit.Case - doctest IslandsEngine - - test "greets the world" do - assert IslandsEngine.hello() == :world - end -end From 1d8f963351629dc7920bd937627db561eb66a393 Mon Sep 17 00:00:00 2001 From: Rowa Date: Thu, 30 May 2024 14:08:37 +0300 Subject: [PATCH 02/10] Test game board creation --- lib/battle_ship/board.ex | 1 + test/battle_ship/board_test.exs | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 test/battle_ship/board_test.exs diff --git a/lib/battle_ship/board.ex b/lib/battle_ship/board.ex index 1b1ab94..3bb08b2 100644 --- a/lib/battle_ship/board.ex +++ b/lib/battle_ship/board.ex @@ -3,6 +3,7 @@ defmodule BattleShip.Board do alias BattleShip.{Coordinate, Ship} + @spec new() :: %{} @doc """ Gives a game board. diff --git a/test/battle_ship/board_test.exs b/test/battle_ship/board_test.exs new file mode 100644 index 0000000..4db36d2 --- /dev/null +++ b/test/battle_ship/board_test.exs @@ -0,0 +1,8 @@ +defmodule BattleShip.BoardTest do + use ExUnit.Case + doctest BattleShip + + test "returns a new board" do + assert BattleShip.Board.new() == %{} + end +end From 591080a5273c737515dc8ce210d72d565d474824 Mon Sep 17 00:00:00 2001 From: Rowa Date: Thu, 30 May 2024 15:00:15 +0300 Subject: [PATCH 03/10] Test coordinate creation --- test/battle_ship/board_test.exs | 4 +++- test/battle_ship/coordinate_test.exs | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 test/battle_ship/coordinate_test.exs diff --git a/test/battle_ship/board_test.exs b/test/battle_ship/board_test.exs index 4db36d2..3028ddc 100644 --- a/test/battle_ship/board_test.exs +++ b/test/battle_ship/board_test.exs @@ -2,7 +2,9 @@ defmodule BattleShip.BoardTest do use ExUnit.Case doctest BattleShip + alias BattleShip.Board + test "returns a new board" do - assert BattleShip.Board.new() == %{} + assert Board.new() == %{} end end diff --git a/test/battle_ship/coordinate_test.exs b/test/battle_ship/coordinate_test.exs new file mode 100644 index 0000000..09e290a --- /dev/null +++ b/test/battle_ship/coordinate_test.exs @@ -0,0 +1,11 @@ +defmodule BattleShip.CoordinateTest do + use ExUnit.Case + doctest BattleShip + + alias BattleShip.Coordinate + + test "create new valid and invalid coordinate" do + assert Coordinate.new(1, 1) == {:ok, %Coordinate{row: 1, col: 1}} + assert Coordinate.new(11, 13) == {:error, :invalid_coordinate} + end +end From fd879a054c68af59bec9ad3d05c1a20e9c04cafe Mon Sep 17 00:00:00 2001 From: Rowa Date: Thu, 30 May 2024 23:53:16 +0300 Subject: [PATCH 04/10] Test dot type ship creation with valid coordinates --- test/battle_ship/ship_test.exs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 test/battle_ship/ship_test.exs diff --git a/test/battle_ship/ship_test.exs b/test/battle_ship/ship_test.exs new file mode 100644 index 0000000..c7b247d --- /dev/null +++ b/test/battle_ship/ship_test.exs @@ -0,0 +1,19 @@ +defmodule BattleShip.ShipTest do + use ExUnit.Case + doctest BattleShip + + alias BattleShip.Coordinate + alias BattleShip.Ship + + test "create new ship with valid coordinates" do + dot_ship_type = :dot + {:ok, valid_dot_coordinates} = Coordinate.new(1, 1) + + assert Ship.new(dot_ship_type, valid_dot_coordinates) == + {:ok, + %Ship{ + coordinates: MapSet.new([%BattleShip.Coordinate{row: 1, col: 1}]), + hit_coordinates: MapSet.new() + }} + end +end From 095e3c0b8dad62fe1ed8eada0cb81fae07f56ccc Mon Sep 17 00:00:00 2001 From: Rowa Date: Sat, 1 Jun 2024 03:43:58 +0300 Subject: [PATCH 05/10] Update test coverage and code analysis --- .credo.exs | 217 +++++++++++++++++++++++++++++++++++ .dialyzer_ignore.exs | 3 + .github/workflows/elixir.yml | 39 ++++--- .gitignore | 3 + coveralls.json | 6 + mix.exs | 40 ++++++- mix.lock | 10 ++ 7 files changed, 298 insertions(+), 20 deletions(-) create mode 100644 .credo.exs create mode 100644 .dialyzer_ignore.exs create mode 100644 coveralls.json create mode 100644 mix.lock diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..baa1ea2 --- /dev/null +++ b/.credo.exs @@ -0,0 +1,217 @@ +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any config using `mix credo -C `. If no config name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + # + included: [ + "lib/", + "src/", + "test/", + "web/", + "apps/*/lib/", + "apps/*/src/", + "apps/*/test/", + "apps/*/web/" + ], + excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + }, + # + # Load and configure plugins here: + # + plugins: [], + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: [], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: false, + # + # To modify the timeout for parsing files, change this value: + # + parse_timeout: 5000, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: %{ + enabled: [ + # + ## Consistency Checks + # + {Credo.Check.Consistency.ExceptionNames, []}, + {Credo.Check.Consistency.LineEndings, []}, + {Credo.Check.Consistency.ParameterPatternMatching, []}, + {Credo.Check.Consistency.SpaceAroundOperators, []}, + {Credo.Check.Consistency.SpaceInParentheses, []}, + {Credo.Check.Consistency.TabsOrSpaces, []}, + + # + ## Design Checks + # + # You can customize the priority of any check + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, + [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, + {Credo.Check.Design.TagFIXME, []}, + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, [exit_status: 2]}, + + # + ## Readability Checks + # + {Credo.Check.Readability.AliasOrder, []}, + {Credo.Check.Readability.FunctionNames, []}, + {Credo.Check.Readability.LargeNumbers, []}, + {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, + {Credo.Check.Readability.ModuleAttributeNames, []}, + {Credo.Check.Readability.ModuleDoc, []}, + {Credo.Check.Readability.ModuleNames, []}, + {Credo.Check.Readability.ParenthesesInCondition, []}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, + {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, + {Credo.Check.Readability.PredicateFunctionNames, []}, + {Credo.Check.Readability.PreferImplicitTry, []}, + {Credo.Check.Readability.RedundantBlankLines, []}, + {Credo.Check.Readability.Semicolons, []}, + {Credo.Check.Readability.SpaceAfterCommas, []}, + {Credo.Check.Readability.StringSigils, []}, + {Credo.Check.Readability.TrailingBlankLine, []}, + {Credo.Check.Readability.TrailingWhiteSpace, []}, + {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, + {Credo.Check.Readability.VariableNames, []}, + {Credo.Check.Readability.WithSingleClause, []}, + + # + ## Refactoring Opportunities + # + {Credo.Check.Refactor.Apply, []}, + {Credo.Check.Refactor.CondStatements, []}, + {Credo.Check.Refactor.CyclomaticComplexity, []}, + {Credo.Check.Refactor.FilterCount, []}, + {Credo.Check.Refactor.FilterFilter, []}, + {Credo.Check.Refactor.FunctionArity, []}, + {Credo.Check.Refactor.LongQuoteBlocks, []}, + {Credo.Check.Refactor.MapJoin, []}, + {Credo.Check.Refactor.MatchInCondition, []}, + {Credo.Check.Refactor.NegatedConditionsInUnless, []}, + {Credo.Check.Refactor.NegatedConditionsWithElse, []}, + {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.RedundantWithClauseResult, []}, + {Credo.Check.Refactor.RejectReject, []}, + {Credo.Check.Refactor.UnlessWithElse, []}, + {Credo.Check.Refactor.WithClauses, []}, + + # + ## Warnings + # + {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, + {Credo.Check.Warning.BoolOperationOnSameValues, []}, + {Credo.Check.Warning.Dbg, []}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, + {Credo.Check.Warning.IExPry, []}, + {Credo.Check.Warning.IoInspect, []}, + {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, + {Credo.Check.Warning.OperationOnSameValues, []}, + {Credo.Check.Warning.OperationWithConstantResult, []}, + {Credo.Check.Warning.RaiseInsideRescue, []}, + {Credo.Check.Warning.SpecWithStruct, []}, + {Credo.Check.Warning.UnsafeExec, []}, + {Credo.Check.Warning.UnusedEnumOperation, []}, + {Credo.Check.Warning.UnusedFileOperation, []}, + {Credo.Check.Warning.UnusedKeywordOperation, []}, + {Credo.Check.Warning.UnusedListOperation, []}, + {Credo.Check.Warning.UnusedPathOperation, []}, + {Credo.Check.Warning.UnusedRegexOperation, []}, + {Credo.Check.Warning.UnusedStringOperation, []}, + {Credo.Check.Warning.UnusedTupleOperation, []}, + {Credo.Check.Warning.WrongTestFileExtension, []} + ], + disabled: [ + # + # Checks scheduled for next check update (opt-in for now) + {Credo.Check.Refactor.UtcNowTruncate, []}, + + # + # Controversial and experimental checks (opt-in, just move the check to `:enabled` + # and be sure to use `mix credo --strict` to see low priority checks) + # + {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, + {Credo.Check.Consistency.UnusedVariableNames, []}, + {Credo.Check.Design.DuplicatedCode, []}, + {Credo.Check.Design.SkipTestWithoutComment, []}, + {Credo.Check.Readability.AliasAs, []}, + {Credo.Check.Readability.BlockPipe, []}, + {Credo.Check.Readability.ImplTrue, []}, + {Credo.Check.Readability.MultiAlias, []}, + {Credo.Check.Readability.NestedFunctionCalls, []}, + {Credo.Check.Readability.OneArityFunctionInPipe, []}, + {Credo.Check.Readability.OnePipePerLine, []}, + {Credo.Check.Readability.SeparateAliasRequire, []}, + {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, + {Credo.Check.Readability.SinglePipe, []}, + {Credo.Check.Readability.Specs, []}, + {Credo.Check.Readability.StrictModuleLayout, []}, + {Credo.Check.Readability.WithCustomTaggedTuple, []}, + {Credo.Check.Refactor.ABCSize, []}, + {Credo.Check.Refactor.AppendSingleItem, []}, + {Credo.Check.Refactor.DoubleBooleanNegation, []}, + {Credo.Check.Refactor.FilterReject, []}, + {Credo.Check.Refactor.IoPuts, []}, + {Credo.Check.Refactor.MapMap, []}, + {Credo.Check.Refactor.ModuleDependencies, []}, + {Credo.Check.Refactor.NegatedIsNil, []}, + {Credo.Check.Refactor.PassAsyncInTestCases, []}, + {Credo.Check.Refactor.PipeChainStart, []}, + {Credo.Check.Refactor.RejectFilter, []}, + {Credo.Check.Refactor.VariableRebinding, []}, + {Credo.Check.Warning.LazyLogging, []}, + {Credo.Check.Warning.LeakyEnvironment, []}, + {Credo.Check.Warning.MapGetUnsafePass, []}, + {Credo.Check.Warning.MixEnv, []}, + {Credo.Check.Warning.UnsafeToAtom, []} + + # {Credo.Check.Refactor.MapInto, []}, + + # + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } + } + ] +} diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs new file mode 100644 index 0000000..0638d44 --- /dev/null +++ b/.dialyzer_ignore.exs @@ -0,0 +1,3 @@ +[ + {:no_warn, "priv/plts/project.plt"} +] diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 132226f..ad2250f 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -7,33 +7,34 @@ name: Elixir CI on: push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] permissions: contents: read jobs: build: - name: Build and test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Set up Elixir - uses: erlef/setup-beam@61e01a43a562a89bfc54c7f9a378ff67b03e4a21 # v1.16.0 - with: - elixir-version: '1.15.2' # [Required] Define the Elixir version - otp-version: '26.0' # [Required] Define the Erlang/OTP version - - name: Restore dependencies cache - uses: actions/cache@v3 - with: - path: deps - key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} - restore-keys: ${{ runner.os }}-mix- - - name: Install dependencies - run: mix deps.get - - name: Run tests - run: mix test + - uses: actions/checkout@v3 + - name: Set up Elixir + uses: erlef/setup-beam@61e01a43a562a89bfc54c7f9a378ff67b03e4a21 # v1.16.0 + with: + elixir-version: "1.15.2" # [Required] Define the Elixir version + otp-version: "26.0" # [Required] Define the Erlang/OTP version + - name: Restore dependencies cache + uses: actions/cache@v3 + with: + path: deps + key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} + restore-keys: ${{ runner.os }}-mix- + - name: Install dependencies + run: mix deps.get + - name: Run tests + run: mix test + - name: Test Coverage + run: mix coveralls diff --git a/.gitignore b/.gitignore index 0556168..b9992e4 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ battle_ship-*.tar # Temporary files, for example, from tests. /tmp/ + +/priv/plts/*.plt +/priv/plts/*.plt.hash diff --git a/coveralls.json b/coveralls.json new file mode 100644 index 0000000..4a000df --- /dev/null +++ b/coveralls.json @@ -0,0 +1,6 @@ +{ + "coverage_options": { + "minimum_coverage": 11 + }, + "skip_files": [] +} diff --git a/mix.exs b/mix.exs index 28aaa5f..ea69c51 100644 --- a/mix.exs +++ b/mix.exs @@ -6,8 +6,24 @@ defmodule BattleShip.MixProject do app: :battle_ship, version: "0.1.0", elixir: "~> 1.15", + build_embedded: Mix.env() == :prod, start_permanent: Mix.env() == :prod, - deps: deps() + aliases: aliases(), + deps: deps(), + preferred_cli_env: [ + ci: :test, + "ci.test": :test, + coveralls: :test, + "coveralls.detail": :test, + "coveralls.post": :test, + "coveralls.html": :test, + "coveralls.detail": :test, + credo: :test, + dialyzer: :test, + sobelow: :test + ], + test_coverage: [tool: ExCoveralls], + dialyzer: [plt_add_apps: [:ex_unit, :mix], ignore_warnings: "config/dialyzer.ignore"] ] end @@ -22,8 +38,30 @@ defmodule BattleShip.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:excoveralls, "~> 0.18", only: :test}, + {:sobelow, "~> 0.13", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false} # {:dep_from_hexpm, "~> 0.3.0"}, # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} ] end + + defp aliases do + [ + ci: [ + "ci.code_quality", + "ci.test" + ], + "ci.deps_and_security": ["sobelow --config .sobelow-config"], + "ci.code_quality": [ + "compile --force --warnings-as-errors", + "credo --strict", + "dialyzer" + ], + "ci.test": [ + "test --cover --warnings-as-errors" + ] + ] + end end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..721b6c1 --- /dev/null +++ b/mix.lock @@ -0,0 +1,10 @@ +%{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "credo": {:hex, :credo, "1.7.6", "b8f14011a5443f2839b04def0b252300842ce7388f3af177157c86da18dfbeea", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "146f347fb9f8cbc5f7e39e3f22f70acbef51d441baa6d10169dd604bfbc55296"}, + "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, + "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, + "excoveralls": {:hex, :excoveralls, "0.18.1", "a6f547570c6b24ec13f122a5634833a063aec49218f6fff27de9df693a15588c", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d65f79db146bb20399f23046015974de0079668b9abb2f5aac074d078da60b8d"}, + "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, + "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"}, +} From 5db71b71129eeddee1008d5652f308a5279b0e04 Mon Sep 17 00:00:00 2001 From: Rowa Date: Sat, 1 Jun 2024 15:47:39 +0300 Subject: [PATCH 06/10] Update tests --- lib/battle_ship/board.ex | 4 ++-- lib/battle_ship/demo_proc.ex | 3 ++- lib/battle_ship/guesses.ex | 5 ++--- lib/battle_ship/rules.ex | 20 +++++++++++--------- lib/battle_ship/ship.ex | 2 +- test/battle_ship/rules_test.exs | 8 ++++++++ 6 files changed, 26 insertions(+), 16 deletions(-) create mode 100644 test/battle_ship/rules_test.exs diff --git a/lib/battle_ship/board.ex b/lib/battle_ship/board.ex index 3bb08b2..e3c1353 100644 --- a/lib/battle_ship/board.ex +++ b/lib/battle_ship/board.ex @@ -15,7 +15,7 @@ defmodule BattleShip.Board do %{} """ - def new(), do: %{} + def new, do: %{} @spec position_ship(map(), atom(), struct()) :: {:error, :overlapping_ship} | map() @doc """ @@ -46,7 +46,7 @@ defmodule BattleShip.Board do @spec all_ships_positioned?(map()) :: boolean() def all_ships_positioned?(board), do: Enum.all?(Ship.types(), &Map.has_key?(board, &1)) - @spec guess(map(), %Coordinate{}) :: + @spec guess(map(), struct()) :: {:hit, boolean(), :no_win | :win, map()} | {:miss, :none, :no_win, map()} @doc """ whether the guess was a hit or a miss, either :none or the type of ship that was sunk, :win or :no_win, and finally the board map itself diff --git a/lib/battle_ship/demo_proc.ex b/lib/battle_ship/demo_proc.ex index 7d6f18c..64be553 100644 --- a/lib/battle_ship/demo_proc.ex +++ b/lib/battle_ship/demo_proc.ex @@ -1,5 +1,6 @@ defmodule BattleShip.DemoProc do - def loop() do + @moduledoc false + def loop do receive do msg -> IO.puts("I received message: #{msg}") end diff --git a/lib/battle_ship/guesses.ex b/lib/battle_ship/guesses.ex index f79118b..5b657be 100644 --- a/lib/battle_ship/guesses.ex +++ b/lib/battle_ship/guesses.ex @@ -7,9 +7,8 @@ defmodule BattleShip.Guesses do @enforce_keys [:hits, :misses] defstruct [:hits, :misses] - # Elixir’s MapSet data structure guarantees that each member of the MapSet will be unique to cater for the fact that a specific guess can be done more than once. A guess already made will simply be ignored by MapSet. Returns a new guesses struct. - @spec new() :: %BattleShip.Guesses{hits: MapSet.t(), misses: MapSet.t()} - def new(), do: %Guesses{hits: MapSet.new(), misses: MapSet.new()} + @spec new() :: struct() + def new, do: %Guesses{hits: MapSet.new(), misses: MapSet.new()} @spec add(struct(), atom(), struct()) :: map() def add(%Guesses{} = guesses, :hit, %Coordinate{} = coordinate) do diff --git a/lib/battle_ship/rules.ex b/lib/battle_ship/rules.ex index d984ab9..d78167d 100644 --- a/lib/battle_ship/rules.ex +++ b/lib/battle_ship/rules.ex @@ -8,11 +8,7 @@ defmodule BattleShip.Rules do player1: :ships_not_set, player2: :ships_not_set - @spec new() :: %BattleShip.Rules{ - player1: :ships_not_set, - player2: :ships_not_set, - state: :initialized - } + @spec new() :: struct() @doc """ Gives game rules. @@ -28,8 +24,16 @@ defmodule BattleShip.Rules do } """ - def new(), do: %Rules{} - + def new, do: %Rules{} + + @spec check(struct(), atom()) :: + :error + | {:ok, + %{ + :__struct__ => BattleShip.Rules, + :state => :game_over | :player1_turn | :player2_turn | :players_set | :ships_set, + optional(any()) => any() + }} @doc """ Gives game rules and transitions states and actions. Pattern matches for the current game state and actions possible in that state. For any state/event combination that ends up in catchall, we don’t want to transition the state. @@ -42,13 +46,11 @@ defmodule BattleShip.Rules do """ def check(%Rules{state: :initialized} = rules, :add_player) do - # It makes a decision about whether it’s okay to add another player based on the current state of the game. Does not actually add a player. Calling the check/2 function with :add_player when we’re in the :initialized state returns {:ok, } and moves us into the :players_set state {:ok, %Rules{rules | state: :players_set}} end def check(%Rules{state: :players_set} = rules, {:position_ships, player}) do case Map.fetch!(rules, player) do - # If the value for the player key is :ships_not_set, it’s fine for that player to move her ships, so we return {:ok, rules}. If the values is :ships_set, it’s not okay for her to move her ships, so we return :error :ships_set -> :error :ships_not_set -> {:ok, rules} end diff --git a/lib/battle_ship/ship.ex b/lib/battle_ship/ship.ex index e349289..600f8b8 100644 --- a/lib/battle_ship/ship.ex +++ b/lib/battle_ship/ship.ex @@ -85,5 +85,5 @@ defmodule BattleShip.Ship do def sunk?(ship), do: MapSet.equal?(ship.coordinates, ship.hit_coordinates) @spec types() :: [:atoll | :dot | :l_shape | :s_shape | :square] - def types(), do: [:atoll, :dot, :l_shape, :s_shape, :square] + def types, do: [:atoll, :dot, :l_shape, :s_shape, :square] end diff --git a/test/battle_ship/rules_test.exs b/test/battle_ship/rules_test.exs new file mode 100644 index 0000000..546793e --- /dev/null +++ b/test/battle_ship/rules_test.exs @@ -0,0 +1,8 @@ +defmodule BattleShip.RulesTest do + use ExUnit.Case + doctest BattleShip + + test "create a new set of rules" do + assert BattleShip.Rules.new() == %BattleShip.Rules{} + end +end From 97740eebd8b4a46bf921d805cef9b8ca54e598ca Mon Sep 17 00:00:00 2001 From: Rowa Date: Fri, 7 Jun 2024 20:03:53 +0300 Subject: [PATCH 07/10] Update test coverage and clean code --- coveralls.json | 2 +- lib/battle_ship/rules.ex | 10 ++++++++-- mix.exs | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/coveralls.json b/coveralls.json index 4a000df..2bde84e 100644 --- a/coveralls.json +++ b/coveralls.json @@ -1,6 +1,6 @@ { "coverage_options": { - "minimum_coverage": 11 + "minimum_coverage": 14 }, "skip_files": [] } diff --git a/lib/battle_ship/rules.ex b/lib/battle_ship/rules.ex index d78167d..31ea2fe 100644 --- a/lib/battle_ship/rules.ex +++ b/lib/battle_ship/rules.ex @@ -26,7 +26,7 @@ defmodule BattleShip.Rules do """ def new, do: %Rules{} - @spec check(struct(), atom()) :: + @spec check(struct(), atom() | tuple()) :: :error | {:ok, %{ @@ -35,7 +35,8 @@ defmodule BattleShip.Rules do optional(any()) => any() }} @doc """ - Gives game rules and transitions states and actions. Pattern matches for the current game state and actions possible in that state. For any state/event combination that ends up in catchall, we don’t want to transition the state. + Gives game rules and transitions states and actions. Pattern matches for the current game state and actions possible in that state. + For any state/event combination that ends up in catchall, we don’t want to transition the state and so :error is returned. Returns `{:ok, rules}`. @@ -50,13 +51,17 @@ defmodule BattleShip.Rules do end def check(%Rules{state: :players_set} = rules, {:position_ships, player}) do + # state remains the same case Map.fetch!(rules, player) do + # can no longer reposition ships :ships_set -> :error + # can still reposition ships :ships_not_set -> {:ok, rules} end end def check(%Rules{state: :players_set} = rules, {:set_ships, player}) do + # sets ships for player and moves state if both player's ships are set rules = Map.put(rules, player, :ships_set) case both_players_ships_set?(rules) do @@ -88,6 +93,7 @@ defmodule BattleShip.Rules do end end + # catch all state, eg when player1 tries to guess a coordinate when the state is in player2's turn def check(_state, _action), do: :error defp both_players_ships_set?(rules), diff --git a/mix.exs b/mix.exs index ea69c51..b7e9797 100644 --- a/mix.exs +++ b/mix.exs @@ -23,7 +23,7 @@ defmodule BattleShip.MixProject do sobelow: :test ], test_coverage: [tool: ExCoveralls], - dialyzer: [plt_add_apps: [:ex_unit, :mix], ignore_warnings: "config/dialyzer.ignore"] + dialyzer: [plt_add_apps: [:ex_unit, :mix], ignore_warnings: ".dialyzer_ignore.exs"] ] end From 3c142fd935cc4432d4a87934cf13edae52da3f17 Mon Sep 17 00:00:00 2001 From: Rowa Date: Sat, 8 Jun 2024 00:59:38 +0300 Subject: [PATCH 08/10] Update test coverage and function spects --- coveralls.json | 2 +- lib/battle_ship/board.ex | 6 +++--- lib/battle_ship/coordinate.ex | 8 ++++++++ lib/battle_ship/demo_proc.ex | 10 ---------- lib/battle_ship/guesses.ex | 9 +++++++-- 5 files changed, 19 insertions(+), 16 deletions(-) delete mode 100644 lib/battle_ship/demo_proc.ex diff --git a/coveralls.json b/coveralls.json index 2bde84e..7ea4147 100644 --- a/coveralls.json +++ b/coveralls.json @@ -1,6 +1,6 @@ { "coverage_options": { - "minimum_coverage": 14 + "minimum_coverage": 18 }, "skip_files": [] } diff --git a/lib/battle_ship/board.ex b/lib/battle_ship/board.ex index e3c1353..b7f69a8 100644 --- a/lib/battle_ship/board.ex +++ b/lib/battle_ship/board.ex @@ -43,11 +43,11 @@ defmodule BattleShip.Board do end) end - @spec all_ships_positioned?(map()) :: boolean() - def all_ships_positioned?(board), do: Enum.all?(Ship.types(), &Map.has_key?(board, &1)) + @spec all_ships_set?(map()) :: boolean() + def all_ships_set?(board), do: Enum.all?(Ship.types(), &Map.has_key?(board, &1)) @spec guess(map(), struct()) :: - {:hit, boolean(), :no_win | :win, map()} | {:miss, :none, :no_win, map()} + {:hit | :miss, boolean(), :no_win | :win, map()} | {:miss, :none, :no_win, map()} @doc """ whether the guess was a hit or a miss, either :none or the type of ship that was sunk, :win or :no_win, and finally the board map itself diff --git a/lib/battle_ship/coordinate.ex b/lib/battle_ship/coordinate.ex index 8f33528..0db86ae 100644 --- a/lib/battle_ship/coordinate.ex +++ b/lib/battle_ship/coordinate.ex @@ -8,6 +8,14 @@ defmodule BattleShip.Coordinate do @enforce_keys [:row, :col] defstruct [:row, :col] + @type t :: %Coordinate{ + row: 1..10, + col: 1..10 + } + + @spec new(integer(), integer()) :: + {:error, :invalid_coordinate} + | {:ok, t()} def new(row, col) when row in @board_range and col in @board_range, do: {:ok, %Coordinate{row: row, col: col}} diff --git a/lib/battle_ship/demo_proc.ex b/lib/battle_ship/demo_proc.ex deleted file mode 100644 index 64be553..0000000 --- a/lib/battle_ship/demo_proc.ex +++ /dev/null @@ -1,10 +0,0 @@ -defmodule BattleShip.DemoProc do - @moduledoc false - def loop do - receive do - msg -> IO.puts("I received message: #{msg}") - end - - loop() - end -end diff --git a/lib/battle_ship/guesses.ex b/lib/battle_ship/guesses.ex index 5b657be..d3237ae 100644 --- a/lib/battle_ship/guesses.ex +++ b/lib/battle_ship/guesses.ex @@ -7,10 +7,15 @@ defmodule BattleShip.Guesses do @enforce_keys [:hits, :misses] defstruct [:hits, :misses] - @spec new() :: struct() + @type t :: %Guesses{ + hits: MapSet.t(), + misses: MapSet.t() + } + + @spec new() :: t() def new, do: %Guesses{hits: MapSet.new(), misses: MapSet.new()} - @spec add(struct(), atom(), struct()) :: map() + @spec add(struct(), :hit | :miss, Coordinate.t()) :: t() def add(%Guesses{} = guesses, :hit, %Coordinate{} = coordinate) do update_in(guesses.hits, &MapSet.put(&1, coordinate)) end From d675303edf59f32433d2d9c54ee0934c0db62478 Mon Sep 17 00:00:00 2001 From: Rowa Date: Sat, 8 Jun 2024 01:01:41 +0300 Subject: [PATCH 09/10] Fix return error when positioning invalid and overlapping ships --- lib/battle_ship/game.ex | 63 ++++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 33 deletions(-) diff --git a/lib/battle_ship/game.ex b/lib/battle_ship/game.ex index dc01fc4..6a94624 100644 --- a/lib/battle_ship/game.ex +++ b/lib/battle_ship/game.ex @@ -3,8 +3,7 @@ defmodule BattleShip.Game do use GenServer - alias BattleShip.{Board, Coordinate, Guesses, Ship} - alias BattleShip.Rules + alias BattleShip.{Board, Coordinate, Guesses, Rules, Ship} @players [:player1, :player2] @@ -27,11 +26,7 @@ defmodule BattleShip.Game do %{ player1: %{board: map(), guesses: map(), name: String.t()}, player2: %{board: map(), guesses: map(), name: nil}, - rules: %BattleShip.Rules{ - player1: :ships_not_set, - player2: :ships_not_set, - state: :initialized - } + rules: struct() }} def init(name) do player1 = %{name: name, board: Board.new(), guesses: Guesses.new()} @@ -42,33 +37,33 @@ defmodule BattleShip.Game do @spec add_player(pid(), binary()) :: :error | {:reply, :ok, %{}} def add_player(game, name) when is_binary(name), do: GenServer.call(game, {:add_player, name}) - def position_ship(game, player, key, row, col) when player in @players, - do: GenServer.call(game, {:position_ship, player, key, row, col}) + def position_ship(game, player, ship_key, row, col) when player in @players, + do: GenServer.call(game, {:position_ship, player, ship_key, row, col}) def set_ships(game, player) when player in @players, do: GenServer.call(game, {:set_ships, player}) @spec guess_coordinate( pid(), - String.t(), + atom(), integer(), integer() ) :: :error | {:reply, :ok, %{}} - def guess_coordinate(game, player, row, col), + def guess_coordinate(game, player, row, col) when player in @players, do: GenServer.call(game, {:guess_coordinate, player, row, col}) - def handle_call({:guess_coordinate, player_key, row, col}, _from, state_data) do - opponent_key = opponent(player_key) - opponent_board = player_board(state_data, opponent_key) + def handle_call({:guess_coordinate, player, row, col}, _from, state_data) do + opponent = opponent(player) + opponent_board = player_board(state_data, opponent) - with {:ok, rules} <- Rules.check(state_data.rules, {:guess_coordinate, player_key}), + with {:ok, rules} <- Rules.check(state_data.rules, {:guess_coordinate, player}), {:ok, coordinate} <- Coordinate.new(row, col), {hit_or_miss, sunk_ship, win_status, opponent_board} <- Board.guess(opponent_board, coordinate), {:ok, rules} <- Rules.check(rules, {:win_check, win_status}) do state_data - |> update_board(opponent_key, opponent_board) - |> update_guesses(player_key, hit_or_miss, coordinate) + |> update_board(opponent, opponent_board) + |> update_guesses(player, coordinate, hit_or_miss) |> update_rules(rules) |> reply_success({hit_or_miss, sunk_ship, win_status}) else @@ -84,34 +79,36 @@ defmodule BattleShip.Game do board = player_board(state_data, player) with {:ok, rules} <- Rules.check(state_data.rules, {:set_ships, player}), - true <- Board.all_ships_positioned?(board) do + true <- Board.all_ships_set?(board) do state_data |> update_rules(rules) |> reply_success({:ok, board}) else :error -> {:reply, :error, state_data} - false -> {:reply, {:error, :not_all_ships_positioned}, state_data} + false -> {:reply, {:error, :not_all_ships_set}, state_data} end end def handle_call({:add_player, name}, _from, state_data) do - with {:ok, rules} <- Rules.check(state_data.rules, :add_player) do - state_data - |> update_player2_name(name) - |> update_rules(rules) - |> reply_success(:ok) - else - :error -> {:noreply, :error, state_data} + case Rules.check(state_data.rules, :add_player) do + {:ok, rules} -> + state_data + |> update_player2_name(name) + |> update_rules(rules) + |> reply_success(:ok) + + :error -> + {:noreply, :error, state_data} end end - def handle_call({:position_ship, player, key, row, col}, _from, state_data) do + def handle_call({:position_ship, player, ship_key, row, col}, _from, state_data) do board = player_board(state_data, player) with {:ok, rules} <- Rules.check(state_data.rules, {:position_ships, player}), {:ok, coordinate} <- Coordinate.new(row, col), - {:ok, ship} <- Ship.new(key, coordinate), - %{} = board <- Board.position_ship(board, key, ship) do + {:ok, ship} <- Ship.new(ship_key, coordinate), + %{} = board <- Board.position_ship(board, ship_key, ship) do state_data |> update_board(player, board) |> update_rules(rules) @@ -124,10 +121,10 @@ defmodule BattleShip.Game do {:reply, {:error, :invalid_coordinate}, state_data} {:error, :invalid_ship_type} -> - {:error, {:error, :invalid_ship_type}, state_data} + {:reply, {:error, :invalid_ship_type}, state_data} {:error, :overlapping_ship} -> - {:error, {:error, :overlapping_ship}, state_data} + {:reply, {:error, :overlapping_ship}, state_data} end end @@ -145,8 +142,8 @@ defmodule BattleShip.Game do defp opponent(:player1), do: :player2 defp opponent(:player2), do: :player1 - defp update_guesses(state_data, player_key, hit_or_miss, coordinate) do - update_in(state_data[player_key].guesses, fn guesses -> + defp update_guesses(state_data, player, coordinate, hit_or_miss) do + update_in(state_data[player].guesses, fn guesses -> Guesses.add(guesses, hit_or_miss, coordinate) end) end From f3e1789e402b027582fbb59351fe3132d02a20d6 Mon Sep 17 00:00:00 2001 From: Rowa Date: Sat, 8 Jun 2024 01:02:45 +0300 Subject: [PATCH 10/10] Test starting a new game --- test/battle_ship/coordinate_test.exs | 1 + test/battle_ship/game_test.exs | 16 ++++++++++++++++ test/support/fixtures/coordinate_fixtures.ex | 6 ++++++ 3 files changed, 23 insertions(+) create mode 100644 test/battle_ship/game_test.exs create mode 100644 test/support/fixtures/coordinate_fixtures.ex diff --git a/test/battle_ship/coordinate_test.exs b/test/battle_ship/coordinate_test.exs index 09e290a..0bd51b4 100644 --- a/test/battle_ship/coordinate_test.exs +++ b/test/battle_ship/coordinate_test.exs @@ -3,6 +3,7 @@ defmodule BattleShip.CoordinateTest do doctest BattleShip alias BattleShip.Coordinate + # alias BattleShip.CoordinateFixtures test "create new valid and invalid coordinate" do assert Coordinate.new(1, 1) == {:ok, %Coordinate{row: 1, col: 1}} diff --git a/test/battle_ship/game_test.exs b/test/battle_ship/game_test.exs new file mode 100644 index 0000000..65b3e36 --- /dev/null +++ b/test/battle_ship/game_test.exs @@ -0,0 +1,16 @@ +defmodule BattleShip.GameTest do + use ExUnit.Case + doctest BattleShip + + alias BattleShip.Game + + test "starts a new game and ignore an already started game" do + assert {:ok, pid} = Game.start_link("test") + + assert is_pid(pid) + + assert :ignore = Game.start_link("test") + + GenServer.stop(pid) + end +end diff --git a/test/support/fixtures/coordinate_fixtures.ex b/test/support/fixtures/coordinate_fixtures.ex new file mode 100644 index 0000000..1a80b84 --- /dev/null +++ b/test/support/fixtures/coordinate_fixtures.ex @@ -0,0 +1,6 @@ +defmodule BattleShip.CoordinateFixtures do + @moduledoc false + def valid_coordinate do + %BattleShip.Coordinate{row: 1, col: 1} + end +end