From 36e62d8bd9d912b7936967c5b6603c3571ffb9ea Mon Sep 17 00:00:00 2001 From: Denver Smith Date: Sun, 20 Dec 2020 15:48:04 -0700 Subject: [PATCH] Fix neighbor bug Remove GenServer --- lib/sternhalma.ex | 274 ++++++++++------------------------ lib/sternhalma/board.ex | 34 ++++- lib/sternhalma/hex.ex | 19 ++- lib/sternhalma/pathfinding.ex | 23 +-- mix.exs | 2 +- test/pathfinding_test.exs | 32 ++++ test/sternhalma_test.exs | 115 +++++--------- 7 files changed, 201 insertions(+), 298 deletions(-) diff --git a/lib/sternhalma.ex b/lib/sternhalma.ex index 82981d2..f907e60 100644 --- a/lib/sternhalma.ex +++ b/lib/sternhalma.ex @@ -2,225 +2,105 @@ defmodule Sternhalma do @moduledoc """ """ - use GenServer + alias Sternhalma.{Board, Cell, Hex} - alias Sternhalma.{Board, Cell, Pathfinding} + @doc """ + Return {x, y} pixel coordinates for a given Hex coordinate. - @type game_status :: :setup | :playing | :over + ## Examples - @type game_state :: %{ - game_id: binary(), - board: Board.t(), - turn: nil | String.t(), - last_move: list(Cell.t()), - status: game_status(), - players: list(String.t()) - } + iex> to_pixel(Sternhalma.Hex.new({1, -4, 3})) + {8.267949192431123, 4.0} - def start_link(game_id) do - name = via_tuple(game_id) - GenServer.start_link(__MODULE__, game_id, name: name) - end - - def add_player(game_id, player_name) do - GenServer.call(via_tuple(game_id), {:add_player, player_name}) - end - - def remove_player(game_id, player_name) do - GenServer.call(via_tuple(game_id), {:remove_player, player_name}) - end - - def set_players(game_id, player_names) do - GenServer.call(via_tuple(game_id), {:set_players, player_names}) - end - - def play_game(game_id) do - GenServer.call(via_tuple(game_id), {:set_status, :playing}) - end - - def end_game(game_id) do - GenServer.call(via_tuple(game_id), {:set_status, :over}) - end - - def move_marble(game_id, start_position, end_position) do - GenServer.call(via_tuple(game_id), {:find_path, start_position, end_position}) - end - - # - # | | - # \/ Server API \/ - - @impl true - def init(game_id) do - initial_state = %{ - game_id: game_id, - board: Board.empty(), - status: :setup, - turn: nil, - last_move: [], - players: [] - } - - {:ok, initial_state} - end - @impl true - def handle_call({:add_player, player_name}, _from, state) do - new_state = add_player_impl(state, player_name) + """ + @spec to_pixel(Hex.t()) :: {number(), number()} + defdelegate to_pixel(position), to: Hex - {:reply, {:ok, new_state}, new_state} - end + @doc """ + Return Hex coordinate for a given pixel coordinate {x, y}. - def handle_call({:remove_player, player_name}, _from, state) do - game_state = %{ - state - | players: [], - board: Board.empty() - } - - # reset the game board and players to empty, - # then re-add new players (except the one to be removed) - new_state = - state.players - |> Enum.filter(&(&1 != player_name)) - |> Enum.reduce(game_state, &add_player_impl(&2, &1)) - - {:reply, {:ok, new_state}, new_state} - end + ## Examples - def handle_call({:set_players, player_names}, _from, state) do - game_state = %{ - state - | players: [], - board: Board.empty() - } + iex> from_pixel({8.267949192431123, 4.0}) + %Sternhalma.Hex{x: 1, y: 3, z: -4} - new_state = - player_names - |> Enum.reduce(game_state, &add_player_impl(&2, &1)) - - {:reply, {:ok, new_state}, new_state} - end - def handle_call({:find_path, start_position, end_position}, _from, state) do - %{turn: turn} = state - - # TODO refactor this function - with( - {:ok, %Cell{marble: ^turn} = start_cell} <- - Board.get_board_cell(state.board, start_position), - {:ok, end_cell} <- Board.get_board_cell(state.board, end_position) - ) do - {result, path} = find_path(state.board, start_cell, end_cell) - - board = - state.board - |> Enum.map(fn board_cell -> - cond do - board_cell.position == start_cell.position -> - Cell.set_marble(board_cell, nil) - - board_cell.position == end_cell.position -> - Cell.set_marble(board_cell, state.turn) - - true -> - board_cell - end - end) - - new_state = %{ - state - | board: board, - last_move: path, - turn: next_turn(state.players, state.turn) - } - - {:reply, {result, new_state}, new_state} - else - _ -> - {:reply, {:error, "invalid start or end position"}, state} - end - end - - def handle_call({:set_status, status}, _from, state) do - {result, new_state} = - state - |> change_game_status(status) - |> perform_side_effects(status) + """ + @spec from_pixel({number(), number()}) :: Hex.t() + defdelegate from_pixel(position), to: Hex - {:reply, {result, new_state}, new_state} + @doc """ + Move a marble from one cell on the board to another. + The function does not take into account if there is a + valid path between the two cells. + """ + @spec move_marble(Board.t(), String.t(), Cell.t(), Cell.t()) :: Board.t() + def move_marble(board, marble, from, to) do + Enum.map(board, fn cell -> + cond do + cell.position == from.position -> + Cell.set_marble(cell, nil) + + cell.position == to.position -> + Cell.set_marble(cell, marble) + + true -> + cell + end + end) end - # TODO consider moving these private functions to some other module + @doc """ + Generate an empty board. + """ + @spec empty_board() :: Board.t() + defdelegate empty_board(), to: Board, as: :empty - @spec change_game_status(game_state(), game_status()) :: {:ok | :error, game_state()} - defp change_game_status(game_state, :playing) - when length(game_state.players) > 1 and game_state.status == :setup, - do: {:ok, %{game_state | status: :playing}} + @doc """ + Return a cell from the game board based on pixel coordinates, x and y. + Return nil if the cell does not exist. - defp change_game_status(game_state, :over) when game_state.status == :playing, - do: {:ok, %{game_state | status: :over}} - defp change_game_status(game_state, _), do: {:error, game_state} + ## Examples - @spec perform_side_effects({:ok | :error, game_state()}, game_status()) :: - {:ok | :error, game_state()} - defp perform_side_effects({:ok, game_state}, :playing) do - {:ok, %{game_state | turn: List.first(game_state.players)}} - end + iex> get_board_cell(empty_board(), {17.794, 14.5}) + {:ok, %Sternhalma.Cell{marble: nil, position: %Sternhalma.Hex{x: 3, y: -6, z: 3}}} - defp perform_side_effects({:ok, game_state}, _), do: {:ok, game_state} - defp perform_side_effects({:error, game_state}, _), do: {:error, game_state} - - @spec add_player_impl(game_state(), String.t()) :: game_state() - defp add_player_impl(game_state, player_name) do - number_of_existing_players = length(game_state.players) - - %{ - game_state - | players: [player_name | game_state.players], - board: - Board.setup_triangle( - game_state.board, - position_opponent(number_of_existing_players), - player_name - ) - } - end + iex> get_board_cell(empty_board(), {172.794, -104.5}) + {:error, nil} - @spec position_opponent(0..5) :: Board.home_triangle() - defp position_opponent(0), do: :top - defp position_opponent(1), do: :bottom - defp position_opponent(2), do: :top_left - defp position_opponent(3), do: :bottom_right - defp position_opponent(4), do: :top_right - defp position_opponent(5), do: :bottom_left - - @spec find_path(Board.t(), Cell.t(), Cell.t()) :: {:ok | :error, list(Cell.t())} - defp find_path(board, start_cell, end_cell) do - result_path = Pathfinding.path(board, start_cell, end_cell) - {pathfinding_status(result_path), result_path} - end - @spec pathfinding_status(list(Cell.t())) :: :ok | :error - defp pathfinding_status([]), do: :error - defp pathfinding_status(_path), do: :ok + """ + @spec get_board_cell(Board.t(), {number(), number()}) :: {:ok | :error, Cell.t() | nil} + defdelegate get_board_cell(board, position), to: Board - @spec next_turn(list(String.t()), String.t()) :: String.t() - defp next_turn(players, turn) do - next_player_index = - case Enum.find_index(players, &(&1 == turn)) do - nil -> - 0 + @doc """ + Add new marbles to the board. - current_player_index -> - rem(current_player_index + 1, length(players)) - end - - Enum.at(players, next_player_index) + The location of the marbles being added is determined based + on the number of unique marbles that are already on the board. + """ + @spec setup_marbles(Board.t(), String.t()) :: {:ok, Board.t()} | {:error, :board_full} + def setup_marbles(board, marble) do + unique_existing_marble_count = Board.count_marbles(board) + + with {:ok, triangle_location} <- Board.position_opponent(unique_existing_marble_count) do + {:ok, + Board.setup_triangle( + board, + triangle_location, + marble + )} + else + {:error, _} -> + {:error, :board_full} + end end - defp via_tuple(game_id) do - {:via, Registry, {:sternhalma_registry, game_id}} - end + @doc """ + Return the list of unique marbles found on a game board. + """ + @spec unique_marbles(Board.t()) :: list(String.t()) + defdelegate unique_marbles(board), to: Board end diff --git a/lib/sternhalma/board.ex b/lib/sternhalma/board.ex index ae30bcb..96704fa 100644 --- a/lib/sternhalma/board.ex +++ b/lib/sternhalma/board.ex @@ -10,7 +10,7 @@ defmodule Sternhalma.Board do @doc """ Generate an empty board. """ - @spec empty() :: list(Cell.t()) + @spec empty() :: t() def empty() do six_point_star() |> Enum.map(&%Cell{position: &1}) @@ -207,4 +207,36 @@ defmodule Sternhalma.Board do |> Enum.zip() |> Enum.map(&Hex.new(&1)) end + + @doc """ + Return the list of unique marbles found on a game board. + """ + def unique_marbles(board) do + {_, marbles} = + Enum.reduce(board, {%{}, []}, fn cell, {memory, marbles} -> + if cell.marble != nil and Map.get(memory, cell.marble) == nil do + {Map.put(memory, cell.marble, true), [cell.marble | marbles]} + else + {memory, marbles} + end + end) + + marbles + end + + @spec count_marbles(t()) :: number() + def count_marbles(board) do + board + |> unique_marbles() + |> Enum.count() + end + + @spec position_opponent(0..5) :: {:ok, home_triangle()} | {:error, nil} + def position_opponent(0), do: {:ok, :top} + def position_opponent(1), do: {:ok, :bottom} + def position_opponent(2), do: {:ok, :top_left} + def position_opponent(3), do: {:ok, :bottom_right} + def position_opponent(4), do: {:ok, :top_right} + def position_opponent(5), do: {:ok, :bottom_left} + def position_opponent(_), do: {:error, nil} end diff --git a/lib/sternhalma/hex.ex b/lib/sternhalma/hex.ex index 9fafa45..289667b 100644 --- a/lib/sternhalma/hex.ex +++ b/lib/sternhalma/hex.ex @@ -36,18 +36,18 @@ defmodule Sternhalma.Hex do ## Examples - iex> neighbor(Sternhalma.Hex.new({1, -4, 3}), :bottom_left) + iex> neighbor(Sternhalma.Hex.new({1, -4, 3}), :top_left) %Sternhalma.Hex{x: 0, y: 3, z: -3} """ @spec neighbor(t(), direction()) :: t() - def neighbor(hex, :top_left), do: %Hex{x: hex.x, z: hex.z - 1, y: hex.y + 1} - def neighbor(hex, :top_right), do: %Hex{x: hex.x + 1, z: hex.z - 1, y: hex.y} + def neighbor(hex, :bottom_left), do: %Hex{x: hex.x, z: hex.z - 1, y: hex.y + 1} + def neighbor(hex, :bottom_right), do: %Hex{x: hex.x + 1, z: hex.z - 1, y: hex.y} def neighbor(hex, :left), do: %Hex{x: hex.x - 1, z: hex.z, y: hex.y + 1} def neighbor(hex, :right), do: %Hex{x: hex.x + 1, z: hex.z, y: hex.y - 1} - def neighbor(hex, :bottom_left), do: %Hex{x: hex.x - 1, z: hex.z + 1, y: hex.y} - def neighbor(hex, :bottom_right), do: %Hex{x: hex.x, z: hex.z + 1, y: hex.y - 1} + def neighbor(hex, :top_left), do: %Hex{x: hex.x - 1, z: hex.z + 1, y: hex.y} + def neighbor(hex, :top_right), do: %Hex{x: hex.x, z: hex.z + 1, y: hex.y - 1} @doc """ Return the surrounding Hex coordinates. @@ -56,15 +56,14 @@ defmodule Sternhalma.Hex do iex> neighbors(Sternhalma.Hex.new({1, -4, 3})) [ - top_left: %Sternhalma.Hex{x: 1, y: 4, z: -5}, - top_right: %Sternhalma.Hex{x: 2, y: 3, z: -5}, + top_left: %Sternhalma.Hex{x: 0, y: 3, z: -3}, + top_right: %Sternhalma.Hex{x: 1, y: 2, z: -3}, left: %Sternhalma.Hex{x: 0, y: 4, z: -4}, right: %Sternhalma.Hex{x: 2, y: 2, z: -4}, - bottom_left: %Sternhalma.Hex{x: 0, y: 3, z: -3}, - bottom_right: %Sternhalma.Hex{x: 1, y: 2, z: -3} + bottom_left: %Sternhalma.Hex{x: 1, y: 4, z: -5}, + bottom_right: %Sternhalma.Hex{x: 2, y: 3, z: -5} ] - """ @spec neighbors(t()) :: list({direction(), t()}) def neighbors(hex) do diff --git a/lib/sternhalma/pathfinding.ex b/lib/sternhalma/pathfinding.ex index 4193448..2562f07 100644 --- a/lib/sternhalma/pathfinding.ex +++ b/lib/sternhalma/pathfinding.ex @@ -29,11 +29,10 @@ defmodule Sternhalma.Pathfinding do paths = jump_move( board, - nil, start, finish, %{start => :done}, - [start] + [{nil, start}] ) backtrack(paths, finish, []) @@ -62,16 +61,16 @@ defmodule Sternhalma.Pathfinding do @type jump_direction :: nil | Hex.direction() - @spec jump_move(Board.t(), jump_direction(), Cell.t(), Cell.t(), path_guide(), list(Cell.t())) :: + @spec jump_move(Board.t(), Cell.t(), Cell.t(), path_guide(), list({jump_direction(), Cell.t()})) :: path_guide() - defp jump_move(_board, _direction, _start, _finish, came_from, []), do: came_from + defp jump_move(_board, _start, _finish, came_from, []), do: came_from - defp jump_move(_board, _direction, _start, finish, came_from, [current | _cells]) + defp jump_move(_board, _start, finish, came_from, [{_direction, current} | _cells]) when finish.position == current.position do came_from end - defp jump_move(board, direction, start, finish, came_from, [current | cells]) do + defp jump_move(board, start, finish, came_from, [{direction, current} | cells]) do neighboring_cells = current |> neighbors(direction) @@ -80,16 +79,18 @@ defmodule Sternhalma.Pathfinding do |> filter_occupied_cells(direction) |> remove_visited_cells(came_from) - direction = get_next_direction(neighboring_cells) - next_cells = neighboring_cells - |> Enum.map(fn {_direction, cell} -> cell end) |> Kernel.++(cells) - came_from = update_came_from(came_from, current, next_cells) + came_from = + update_came_from( + came_from, + current, + Enum.map(next_cells, fn {_direction, cell} -> cell end) + ) - jump_move(board, direction, start, finish, came_from, next_cells) + jump_move(board, start, finish, came_from, next_cells) end @spec update_came_from(path_guide(), Cell.t(), list(Cell.t())) :: path_guide() diff --git a/mix.exs b/mix.exs index 07779bc..d55d13c 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Sternhalma.MixProject do def project do [ app: :sternhalma, - version: "0.1.0", + version: "0.1.1", elixir: "~> 1.10", start_permanent: Mix.env() == :prod, deps: deps() diff --git a/test/pathfinding_test.exs b/test/pathfinding_test.exs index 56c92b4..0056c1f 100644 --- a/test/pathfinding_test.exs +++ b/test/pathfinding_test.exs @@ -224,4 +224,36 @@ defmodule PathfindingTest do assert Pathfinding.path(board, start, finish) == [] end + + test "(11.7, 4) -> (10, 7) is valid", _state do + # + # o = empty cell + # x = cell with marble + # s = start + # f = finish + # o + # o o + # o o o + # o o o o + # o o o o o o o o o o o o o + # o o o o o o o o o o o o + # o o o o o o o o o o o + # o o o o o o o o o o + # o o o o o o o o o + # o o o o o o o o o o + # o o o o o o o o o o o + # o o o o o o o o o o o o + # o o o o o o f o o o o o o + # o o x x + # o o s + # o o + # o + # + + start = %Cell{marble: 'a', position: Hex.from_pixel({11.732, 4})} + finish = %Cell{position: Hex.from_pixel({10, 7})} + board = setup_board([{12.5, 5.5}, {10.866, 5.5}]) + + assert Pathfinding.path(board, start, finish) != [] + end end diff --git a/test/sternhalma_test.exs b/test/sternhalma_test.exs index 0ec48bd..28e2625 100644 --- a/test/sternhalma_test.exs +++ b/test/sternhalma_test.exs @@ -1,94 +1,53 @@ defmodule SternhalmaTest do use ExUnit.Case, async: true - doctest Sternhalma - - @game_id "testing" - - setup do - game_pid = start_supervised!({Sternhalma, @game_id}) - %{game_pid: game_pid} - end - - test "add a player to a game in with :setup status", %{game_pid: game_pid} do - assert :sys.get_state(game_pid).status == :setup - assert {:ok, _game} = Sternhalma.add_player(@game_id, "a") - assert {:ok, game} = Sternhalma.add_player(@game_id, "b") - assert length(Enum.filter(game.board, &(&1.marble == "a"))) == 10 - assert length(Enum.filter(game.board, &(&1.marble == "b"))) == 10 + doctest Sternhalma, import: true + + alias Sternhalma.{Board, Cell, Hex} + + defp setup_board(occupied_locations) do + Enum.map(Board.empty(), fn cell -> + if Enum.any?(occupied_locations, fn point -> + cell.position == Hex.from_pixel(point) + end) do + Cell.set_marble(cell, 'a') + else + cell + end + end) end - test "add three players then remove two" do - assert {:ok, _game} = Sternhalma.add_player(@game_id, "a") - assert {:ok, _game} = Sternhalma.add_player(@game_id, "b") - assert {:ok, _game} = Sternhalma.add_player(@game_id, "c") - assert {:ok, _game} = Sternhalma.remove_player(@game_id, "a") - assert {:ok, game} = Sternhalma.remove_player(@game_id, "b") - assert length(Enum.filter(game.board, &(&1.marble == "a"))) == 0 - assert length(Enum.filter(game.board, &(&1.marble == "b"))) == 0 - assert length(Enum.filter(game.board, &(&1.marble == "c"))) == 10 - end + test "move a marble" do + from = %Cell{marble: "a", position: Hex.from_pixel({10, 1})} + to = %Cell{position: Hex.from_pixel({8.268, 4})} + board = setup_board([{10, 1}]) - test "set players" do - assert {:ok, _game} = Sternhalma.add_player(@game_id, "z") - assert {:ok, game} = Sternhalma.set_players(@game_id, ["a", "b", "c"]) - assert length(Enum.filter(game.board, &(&1.marble == "a"))) == 10 - assert length(Enum.filter(game.board, &(&1.marble == "b"))) == 10 - assert length(Enum.filter(game.board, &(&1.marble == "c"))) == 10 - assert length(Enum.filter(game.board, &(&1.marble == "z"))) == 0 - end + board = Sternhalma.move_marble(board, "a", from, to) - test "cannot set status to :playing with less than two players", %{game_pid: game_pid} do - assert :sys.get_state(game_pid).status == :setup - assert {:ok, _game} = Sternhalma.add_player(@game_id, "a") - assert {:error, game} = Sternhalma.play_game(@game_id) - assert game.status == :setup + assert {:ok, %Cell{marble: nil}} = Board.get_board_cell(board, {10, 1}) + assert {:ok, %Cell{marble: "a"}} = Board.get_board_cell(board, {8.268, 4}) end - test "set status to :playing then to :over", %{game_pid: game_pid} do - assert :sys.get_state(game_pid).status == :setup - assert {:ok, _game} = Sternhalma.add_player(@game_id, "a") - assert {:ok, _game} = Sternhalma.add_player(@game_id, "b") + test "moving a marble does not change the board if invalid cells are used" do + from = %Cell{marble: "a", position: Hex.from_pixel({100, 15})} + to = %Cell{position: Hex.from_pixel({1_919_191, 42222})} + board = setup_board([{10, 1}]) - assert {:error, _game} = Sternhalma.end_game(@game_id) + board = Sternhalma.move_marble(board, "a", from, to) - assert {:ok, game} = Sternhalma.play_game(@game_id) - assert game.status == :playing - refute game.turn == nil - - assert {:ok, game} = Sternhalma.end_game(@game_id) - assert game.status == :over + assert board == board end - describe "gameplay" do - setup %{game_pid: game_pid} do - {:ok, _game} = Sternhalma.add_player(@game_id, "a") - {:ok, _game} = Sternhalma.add_player(@game_id, "b") - {:ok, _game} = Sternhalma.play_game(@game_id) - - %{game_pid: game_pid} - end - - test "players cannot move when it's not their turn", %{game_pid: game_pid} do - assert %{turn: "b"} = :sys.get_state(game_pid) - - assert {:error, "invalid start or end position"} = - Sternhalma.move_marble(@game_id, {12.598, 20.5}, {11.732, 19}) - end - - test "players alternate turns moving marbles", %{game_pid: game_pid} do - assert %{turn: "b"} = :sys.get_state(game_pid) - - assert {:ok, game} = Sternhalma.move_marble(@game_id, {9.134, 5.5}, {10, 7}) - assert %{turn: "a", last_move: last_move} = game - assert length(last_move) > 0 + test "setup marbles adds groups of marbles in correct places" do + board = Sternhalma.empty_board() - assert {:ok, game} = Sternhalma.move_marble(@game_id, {12.598, 20.5}, {11.732, 19}) - assert %{turn: "b", last_move: last_move} = game - assert length(last_move) > 0 + # TODO: test which marbles are in which triangles - assert {:ok, game} = Sternhalma.move_marble(@game_id, {10.866, 5.5}, {9.134, 8.5}) - assert %{turn: "a", last_move: last_move} = game - assert length(last_move) > 0 - end + assert {:ok, board} = Sternhalma.setup_marbles(board, "red") + assert {:ok, board} = Sternhalma.setup_marbles(board, "green") + assert {:ok, board} = Sternhalma.setup_marbles(board, "blue") + assert {:ok, board} = Sternhalma.setup_marbles(board, "black") + assert {:ok, board} = Sternhalma.setup_marbles(board, "yellow") + assert {:ok, board} = Sternhalma.setup_marbles(board, "white") + assert {:error, :board_full} = Sternhalma.setup_marbles(board, "purple") end end