├── test ├── test_helper.exs ├── hex_test.exs ├── cell_test.exs ├── board_test.exs ├── sternhalma_test.exs └── pathfinding_test.exs ├── .formatter.exs ├── README.md ├── .gitignore ├── mix.exs ├── lib ├── sternhalma │ ├── cell.ex │ ├── hex.ex │ ├── pathfinding.ex │ └── board.ex └── sternhalma.ex └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/hex_test.exs: -------------------------------------------------------------------------------- 1 | defmodule HexTest do 2 | use ExUnit.Case, async: true 3 | doctest Sternhalma.Hex, import: true 4 | end 5 | -------------------------------------------------------------------------------- /test/cell_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CellTest do 2 | use ExUnit.Case, async: true 3 | doctest Sternhalma.Cell, import: true 4 | end 5 | -------------------------------------------------------------------------------- /test/board_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BoardTest do 2 | use ExUnit.Case, async: true 3 | doctest Sternhalma.Board, import: true 4 | end 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sternhalma 2 | 3 | Provides a set of functions for building a Chinese Checkers game. 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `sternhalma` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:sternhalma, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at [https://hexdocs.pm/sternhalma](https://hexdocs.pm/sternhalma). 21 | 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | sternhalma-*.tar 24 | 25 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Sternhalma.MixProject do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/denvaar/sternhalma" 5 | 6 | def project() do 7 | [ 8 | app: :sternhalma, 9 | version: "0.1.1", 10 | elixir: "~> 1.10", 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps(), 13 | package: package(), 14 | source_url: @source_url, 15 | description: """ 16 | Provides a set of functions for making a Chinese Checkers game. 17 | """, 18 | name: "Sternhalma" 19 | ] 20 | end 21 | 22 | defp package() do 23 | [ 24 | licenses: ["MIT"], 25 | links: %{"GitHub" => @source_url} 26 | ] 27 | end 28 | 29 | # Run "mix help compile.app" to learn about applications. 30 | def application() do 31 | [ 32 | extra_applications: [:logger] 33 | ] 34 | end 35 | 36 | # Run "mix help deps" to learn about dependencies. 37 | defp deps() do 38 | [ 39 | {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, 40 | {:ex_doc, "~> 0.14", only: :dev, runtime: false} 41 | ] 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/sternhalma/cell.ex: -------------------------------------------------------------------------------- 1 | defmodule Sternhalma.Cell do 2 | alias __MODULE__ 3 | alias Sternhalma.Hex 4 | 5 | @enforce_keys [:position] 6 | defstruct [:position, :marble, :target] 7 | 8 | @typedoc """ 9 | Represents a single spot on the board. 10 | """ 11 | @type t :: %Cell{position: Hex.t(), marble: marble(), target: marble()} 12 | 13 | @type marble :: nil | String.t() 14 | 15 | @doc """ 16 | Puts a marble in the given cell. 17 | 18 | ## Examples 19 | 20 | iex> set_marble(%Sternhalma.Cell{position: Sternhalma.Hex.new({0,0,0})}, "a") 21 | %Sternhalma.Cell{marble: "a", position: %Sternhalma.Hex{x: 0, y: 0, z: 0}} 22 | 23 | 24 | """ 25 | @spec set_marble(t(), marble()) :: t() 26 | def set_marble(cell, marble), do: %Cell{cell | marble: marble} 27 | 28 | @doc """ 29 | Set which marble is the target in a given cell. 30 | The target is used to determine if marbles are 31 | located in their winning positions. 32 | 33 | ## Examples 34 | 35 | iex> set_target(%Sternhalma.Cell{position: Sternhalma.Hex.new({0,0,0})}, "a") 36 | %Sternhalma.Cell{target: "a", position: %Sternhalma.Hex{x: 0, y: 0, z: 0}} 37 | 38 | iex> set_target(%Sternhalma.Cell{marble: "b", position: Sternhalma.Hex.new({0,0,0})}, "a") 39 | %Sternhalma.Cell{target: "a", marble: "b", position: %Sternhalma.Hex{x: 0, y: 0, z: 0}} 40 | 41 | 42 | """ 43 | @spec set_target(t(), marble()) :: t() 44 | def set_target(cell, marble), do: %Cell{cell | target: marble} 45 | end 46 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"}, 3 | "earmark_parser": {:hex, :earmark_parser, "1.4.12", "b245e875ec0a311a342320da0551da407d9d2b65d98f7a9597ae078615af3449", [:mix], [], "hexpm", "711e2cc4d64abb7d566d43f54b78f7dc129308a63bc103fbd88550d2174b3160"}, 4 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 5 | "ex_doc": {:hex, :ex_doc, "0.23.0", "a069bc9b0bf8efe323ecde8c0d62afc13d308b1fa3d228b65bca5cf8703a529d", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f5e2c4702468b2fd11b10d39416ddadd2fcdd173ba2a0285ebd92c39827a5a16"}, 6 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 7 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.0", "98312c9f0d3730fde4049985a1105da5155bfe5c11e47bdc7406d88e01e4219b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "75ffa34ab1056b7e24844c90bfc62aaf6f3a37a15faa76b07bc5eba27e4a8b4a"}, 8 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 9 | } 10 | -------------------------------------------------------------------------------- /test/sternhalma_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SternhalmaTest do 2 | use ExUnit.Case, async: true 3 | doctest Sternhalma, import: true 4 | 5 | alias Sternhalma.{Board, Cell, Hex} 6 | 7 | defp setup_board(occupied_locations) do 8 | Enum.map(Board.empty(), fn cell -> 9 | if Enum.any?(occupied_locations, fn point -> 10 | cell.position == Hex.from_pixel(point) 11 | end) do 12 | Cell.set_marble(cell, 'a') 13 | else 14 | cell 15 | end 16 | end) 17 | end 18 | 19 | test "move a marble" do 20 | from = %Cell{marble: "a", position: Hex.from_pixel({10, 1})} 21 | to = %Cell{position: Hex.from_pixel({8.268, 4})} 22 | board = setup_board([{10, 1}]) 23 | 24 | board = Sternhalma.move_marble(board, "a", from, to) 25 | 26 | assert {:ok, %Cell{marble: nil}} = Board.get_board_cell(board, {10, 1}) 27 | assert {:ok, %Cell{marble: "a"}} = Board.get_board_cell(board, {8.268, 4}) 28 | end 29 | 30 | test "moving a marble does not change the board if invalid cells are used" do 31 | from = %Cell{marble: "a", position: Hex.from_pixel({100, 15})} 32 | to = %Cell{position: Hex.from_pixel({1_919_191, 42222})} 33 | board = setup_board([{10, 1}]) 34 | 35 | board = Sternhalma.move_marble(board, "a", from, to) 36 | 37 | assert board == board 38 | end 39 | 40 | test "setup marbles adds groups of marbles in correct places" do 41 | board = Sternhalma.empty_board() 42 | 43 | # TODO: test which marbles are in which triangles 44 | 45 | assert {:ok, board} = Sternhalma.setup_marbles(board, "red") 46 | assert {:ok, board} = Sternhalma.setup_marbles(board, "green") 47 | assert {:ok, board} = Sternhalma.setup_marbles(board, "blue") 48 | assert {:ok, board} = Sternhalma.setup_marbles(board, "black") 49 | assert {:ok, board} = Sternhalma.setup_marbles(board, "yellow") 50 | assert {:ok, board} = Sternhalma.setup_marbles(board, "white") 51 | assert {:error, :board_full} = Sternhalma.setup_marbles(board, "purple") 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/sternhalma.ex: -------------------------------------------------------------------------------- 1 | defmodule Sternhalma do 2 | @moduledoc """ 3 | """ 4 | 5 | alias Sternhalma.{Board, Cell, Pathfinding, Hex} 6 | 7 | @doc """ 8 | Return {x, y} pixel coordinates for a given Hex coordinate. 9 | 10 | ## Examples 11 | 12 | iex> to_pixel(Sternhalma.Hex.new({1, -4, 3})) 13 | {8.267949192431123, 4.0} 14 | 15 | 16 | """ 17 | @spec to_pixel(Hex.t()) :: {number(), number()} 18 | defdelegate to_pixel(position), to: Hex 19 | 20 | @doc """ 21 | Return Hex coordinate for a given pixel coordinate {x, y}. 22 | 23 | ## Examples 24 | 25 | iex> from_pixel({8.267949192431123, 4.0}) 26 | %Sternhalma.Hex{x: 1, y: 3, z: -4} 27 | 28 | 29 | """ 30 | @spec from_pixel({number(), number()}) :: Hex.t() 31 | defdelegate from_pixel(position), to: Hex 32 | 33 | @doc """ 34 | Move a marble from one cell on the board to another. 35 | The function does not take into account if there is a 36 | valid path between the two cells. 37 | """ 38 | @spec move_marble(Board.t(), String.t(), Cell.t(), Cell.t()) :: Board.t() 39 | def move_marble(board, marble, from, to) do 40 | Enum.map(board, fn cell -> 41 | cond do 42 | cell.position == from.position -> 43 | Cell.set_marble(cell, nil) 44 | 45 | cell.position == to.position -> 46 | Cell.set_marble(cell, marble) 47 | 48 | true -> 49 | cell 50 | end 51 | end) 52 | end 53 | 54 | @doc """ 55 | Return a list of board cells from one position to another. 56 | Returns an empty list if there is no path possible. 57 | """ 58 | @spec find_path(Board.t(), Cell.t(), Cell.t()) :: list(Cell.t()) 59 | def find_path(board, from, to) do 60 | Pathfinding.path(board, from, to) 61 | end 62 | 63 | @doc """ 64 | Generate an empty board. 65 | """ 66 | @spec empty_board() :: Board.t() 67 | defdelegate empty_board(), to: Board, as: :empty 68 | 69 | @doc """ 70 | Return a cell from the game board based on pixel coordinates, x and y. 71 | Return nil if the cell does not exist. 72 | 73 | 74 | ## Examples 75 | 76 | iex> get_board_cell(empty_board(), {17.794, 14.5}) 77 | {:ok, %Sternhalma.Cell{marble: nil, position: %Sternhalma.Hex{x: 3, y: -6, z: 3}}} 78 | 79 | iex> get_board_cell(empty_board(), {172.794, -104.5}) 80 | {:error, nil} 81 | 82 | 83 | """ 84 | @spec get_board_cell(Board.t(), {number(), number()}) :: {:ok | :error, Cell.t() | nil} 85 | defdelegate get_board_cell(board, position), to: Board 86 | 87 | @doc """ 88 | Add new marbles to the board. 89 | 90 | The location of the marbles being added is determined based 91 | on the number of unique marbles that are already on the board. 92 | """ 93 | @spec setup_marbles(Board.t(), String.t()) :: {:ok, Board.t()} | {:error, :board_full} 94 | def setup_marbles(board, marble) do 95 | unique_existing_marble_count = Board.count_marbles(board) 96 | 97 | with {:ok, triangle_location} <- Board.position_opponent(unique_existing_marble_count) do 98 | {:ok, 99 | Board.setup_triangle( 100 | board, 101 | triangle_location, 102 | marble 103 | )} 104 | else 105 | {:error, _} -> 106 | {:error, :board_full} 107 | end 108 | end 109 | 110 | @doc """ 111 | Return the list of unique marbles found on a game board. 112 | """ 113 | @spec unique_marbles(Board.t()) :: list(String.t()) 114 | defdelegate unique_marbles(board), to: Board 115 | 116 | @doc """ 117 | Indicates if all the marbles of the given type are located 118 | in their winning locations. 119 | """ 120 | @spec won_game?(Board.t(), String.t()) :: boolean() 121 | def won_game?(board, marble) do 122 | Board.find_winners(board) 123 | |> Enum.any?(&(&1.marble == marble)) 124 | end 125 | 126 | @doc """ 127 | Returns the winner, if there is one. 128 | To win, all 10 marbles must be in their 129 | target positions. 130 | """ 131 | @spec winner(Board.t()) :: String.t() | nil 132 | def winner(board) do 133 | with [[winner | _] | _] <- Board.find_winners(board) do 134 | winner.marble 135 | else 136 | _ -> 137 | nil 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /lib/sternhalma/hex.ex: -------------------------------------------------------------------------------- 1 | defmodule Sternhalma.Hex do 2 | alias __MODULE__ 3 | 4 | defstruct x: 0, z: 0, y: 0 5 | 6 | @typedoc """ 7 | Represents x z y. 8 | 9 | The coordinates x, z, and y must add to 0 in some way. 10 | 11 | See https://www.redblobgames.com/grids/hexagons/#coordinates-cube for more info. 12 | """ 13 | @type t :: %Hex{x: number(), z: number(), y: number()} 14 | 15 | @type direction :: 16 | :top_left 17 | | :top_right 18 | | :left 19 | | :right 20 | | :bottom_left 21 | | :bottom_right 22 | 23 | @doc """ 24 | Return a new Hex struct with coordinates x, z, and y. 25 | Return nil if the coordinates provided do not add to 0. 26 | """ 27 | @spec new({number(), number(), number()}) :: nil | t() 28 | def new({x, z, y}) when x + z + y != 0, do: nil 29 | 30 | def new({x, z, y}) do 31 | %Hex{x: x, z: z, y: y} 32 | end 33 | 34 | @doc """ 35 | Return the next Hex coordinate based on a provided direction. 36 | 37 | ## Examples 38 | 39 | iex> neighbor(Sternhalma.Hex.new({1, -4, 3}), :top_left) 40 | %Sternhalma.Hex{x: 0, y: 3, z: -3} 41 | 42 | 43 | """ 44 | @spec neighbor(t(), direction()) :: t() 45 | def neighbor(hex, :bottom_left), do: %Hex{x: hex.x, z: hex.z - 1, y: hex.y + 1} 46 | def neighbor(hex, :bottom_right), do: %Hex{x: hex.x + 1, z: hex.z - 1, y: hex.y} 47 | def neighbor(hex, :left), do: %Hex{x: hex.x - 1, z: hex.z, y: hex.y + 1} 48 | def neighbor(hex, :right), do: %Hex{x: hex.x + 1, z: hex.z, y: hex.y - 1} 49 | def neighbor(hex, :top_left), do: %Hex{x: hex.x - 1, z: hex.z + 1, y: hex.y} 50 | def neighbor(hex, :top_right), do: %Hex{x: hex.x, z: hex.z + 1, y: hex.y - 1} 51 | 52 | @doc """ 53 | Return the surrounding Hex coordinates. 54 | 55 | ## Examples 56 | 57 | iex> neighbors(Sternhalma.Hex.new({1, -4, 3})) 58 | [ 59 | top_left: %Sternhalma.Hex{x: 0, y: 3, z: -3}, 60 | top_right: %Sternhalma.Hex{x: 1, y: 2, z: -3}, 61 | left: %Sternhalma.Hex{x: 0, y: 4, z: -4}, 62 | right: %Sternhalma.Hex{x: 2, y: 2, z: -4}, 63 | bottom_left: %Sternhalma.Hex{x: 1, y: 4, z: -5}, 64 | bottom_right: %Sternhalma.Hex{x: 2, y: 3, z: -5} 65 | ] 66 | 67 | """ 68 | @spec neighbors(t()) :: list({direction(), t()}) 69 | def neighbors(hex) do 70 | [:top_left, :top_right, :left, :right, :bottom_left, :bottom_right] 71 | |> Enum.map(fn direction -> {direction, neighbor(hex, direction)} end) 72 | end 73 | 74 | @doc """ 75 | Return {x, y} pixel coordinates for a given Hex coordinate. 76 | 77 | ## Examples 78 | 79 | iex> to_pixel(Sternhalma.Hex.new({1, -4, 3})) 80 | {8.267949192431123, 4.0} 81 | 82 | 83 | """ 84 | @spec to_pixel(t()) :: {number(), number()} 85 | def to_pixel(hex) do 86 | size = 1 87 | origin_x = 10 88 | origin_y = 10 89 | 90 | x = (:math.sqrt(3.0) * hex.x + :math.sqrt(3.0) / 2.0 * hex.z) * size 91 | y = (0.0 * hex.x + 3.0 / 2.0 * hex.z) * size 92 | {x + origin_x, y + origin_y} 93 | end 94 | 95 | @doc """ 96 | Return Hex coordinate for a given pixel coordinate {x, y}. 97 | 98 | ## Examples 99 | 100 | iex> from_pixel({8.267949192431123, 4.0}) 101 | %Sternhalma.Hex{x: 1, y: 3, z: -4} 102 | 103 | 104 | """ 105 | @spec from_pixel({number(), number()}) :: t() 106 | def from_pixel({px, py}) do 107 | size = 1 108 | origin_x = 10 109 | origin_y = 10 110 | 111 | pt_x = (px - origin_x) / size 112 | pt_y = (py - origin_y) / size 113 | 114 | x = :math.sqrt(3.0) / 3.0 * pt_x + -1.0 / 3.0 * pt_y 115 | z = 0.0 * pt_x + 2.0 / 3.0 * pt_y 116 | 117 | hex_round(x, z, -x - z) 118 | end 119 | 120 | @spec hex_round(number(), number(), number()) :: t() 121 | defp hex_round(raw_x, raw_z, raw_y) do 122 | x = round(raw_x) 123 | z = round(raw_z) 124 | y = round(raw_y) 125 | 126 | x_diff = abs(x - raw_x) 127 | z_diff = abs(z - raw_z) 128 | y_diff = abs(y - raw_y) 129 | 130 | cond do 131 | x_diff > z_diff and x_diff > y_diff -> 132 | new({-z - y, z, y}) 133 | 134 | z_diff > y_diff -> 135 | new({x, -x - y, y}) 136 | 137 | true -> 138 | new({x, z, -x - z}) 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /lib/sternhalma/pathfinding.ex: -------------------------------------------------------------------------------- 1 | defmodule Sternhalma.Pathfinding do 2 | @moduledoc """ 3 | Provides functions for pathfinding in the context of a game board. 4 | 5 | Pathfinding comes into play when determining if a proposed move is valid. 6 | 7 | A path is considered valid if: 8 | 9 | - The finishing cell is empty and a direct neighbor of the starting cell 10 | - The finishing cell is empty and is reachable via one or more "jump" moves 11 | 12 | A jump is only possible if there's a marble in between the current cell and 13 | an empty cell. 14 | 15 | Marbles can be jumped any number of times, as long as the direction of the jump 16 | doesn't change while not on an empty cell. It is valid to change directions 17 | once an empty cell is reached. 18 | """ 19 | 20 | alias Sternhalma.{Hex, Cell, Board} 21 | 22 | @doc """ 23 | Find and return a list of cells between the given 24 | start and finish cells. 25 | 26 | If there is not a valid path between the two, an empty 27 | list is returned. 28 | 29 | The shortest path possible is returned. 30 | """ 31 | @spec path(Board.t(), Cell.t(), Cell.t()) :: list(Cell.t()) 32 | def path(_board, start, finish) 33 | when start.position == finish.position or start.marble == nil or finish.marble != nil, 34 | do: [] 35 | 36 | def path(board, start, finish) do 37 | case jump_needed?(start, finish) do 38 | false -> 39 | [start, finish] 40 | 41 | true -> 42 | path = bfs(board, finish, %{start => :done}, %{start => true}, [start]) 43 | backtrack(path, finish, []) 44 | end 45 | end 46 | 47 | @type path :: %{Cell.t() => :done | Cell.t()} 48 | @type visited :: %{Hex.t() => true} 49 | 50 | @spec bfs(Board.t(), Cell.t(), path(), visited(), list(Cell.t())) :: path() 51 | defp bfs(_board, target, path, _visited, [current | _to_be_explored]) 52 | when current.position == target.position, 53 | do: path 54 | 55 | defp bfs(_board, _target, path, _visited, []), do: path 56 | 57 | defp bfs(board, target, path, visited, [current | to_be_explored]) do 58 | neighbors = 59 | current.position 60 | |> jumpable_neighbors(board) 61 | |> remove_visited_cells(visited) 62 | 63 | path = 64 | Enum.reduce(neighbors, path, fn neighbor, path_acc -> 65 | Map.put(path_acc, neighbor, current) 66 | end) 67 | 68 | visited = 69 | Enum.reduce(neighbors, visited, fn neighbor, visited_acc -> 70 | Map.put(visited_acc, neighbor.position, true) 71 | end) 72 | 73 | bfs(board, target, path, visited, to_be_explored ++ neighbors) 74 | end 75 | 76 | @doc """ 77 | Return the cells that are reachable one jump move 78 | away from the given position. 79 | 80 | See Hex.neighbors/1 or Hex.neighbor/2 for finding 81 | neighbors in general. 82 | """ 83 | @spec jumpable_neighbors(Hex.t(), Board.t()) :: list(Cell.t()) 84 | def jumpable_neighbors(position, board) do 85 | position 86 | |> Hex.neighbors() 87 | |> Enum.map(fn {direction, position} -> 88 | case Enum.find(board, &(&1.position == position and &1.marble != nil)) do 89 | nil -> 90 | nil 91 | 92 | cell -> 93 | pos = Hex.neighbor(cell.position, direction) 94 | Enum.find(board, &(&1.position == pos and &1.marble == nil)) 95 | end 96 | end) 97 | |> Enum.filter(& &1) 98 | end 99 | 100 | @spec backtrack(path(), nil | Cell.t() | :done, list(Cell.t())) :: list(Cell.t()) 101 | defp backtrack(_path, nil, _result), do: [] 102 | defp backtrack(_path, :done, result), do: result 103 | 104 | defp backtrack(path, finish, result) do 105 | current = Map.get(path, finish) 106 | backtrack(path, current, [finish | result]) 107 | end 108 | 109 | @spec remove_visited_cells(list(Cell.t()), visited()) :: list(Cell.t()) 110 | defp remove_visited_cells(neighbors, visited) do 111 | neighbors 112 | |> Enum.reject(fn cell -> Map.get(visited, cell.position) end) 113 | end 114 | 115 | @spec jump_needed?(Cell.t(), Cell.t()) :: boolean() 116 | defp jump_needed?(start, finish) do 117 | neighbors = Hex.neighbors(start.position) 118 | 119 | !(finish.marble == nil and 120 | Enum.find(neighbors, fn {_direction, hex} -> 121 | hex == finish.position 122 | end)) 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/sternhalma/board.ex: -------------------------------------------------------------------------------- 1 | defmodule Sternhalma.Board do 2 | @moduledoc """ 3 | Provides functions to manipulate the Chinese Checkers game board. 4 | """ 5 | 6 | alias Sternhalma.{Hex, Cell} 7 | 8 | @type t :: list(Cell.t()) 9 | 10 | @doc """ 11 | Generate an empty board. 12 | """ 13 | @spec empty() :: t() 14 | def empty() do 15 | six_point_star() 16 | |> Enum.map(&%Cell{position: &1}) 17 | end 18 | 19 | @type home_triangle :: 20 | :top_left 21 | | :top 22 | | :top_right 23 | | :bottom_left 24 | | :bottom 25 | | :bottom_right 26 | 27 | @doc """ 28 | Fill in a home triangle with marbles. 29 | """ 30 | @spec setup_triangle(t(), home_triangle(), String.t()) :: t() 31 | def setup_triangle(board, :bottom, marble) do 32 | board 33 | |> setup_triangle_helper(bottom_positions(), fn cell -> 34 | Cell.set_marble(cell, marble) 35 | end) 36 | |> setup_triangle_helper(top_positions(), fn cell -> 37 | Cell.set_target(cell, marble) 38 | end) 39 | end 40 | 41 | def setup_triangle(board, :bottom_left, marble) do 42 | board 43 | |> setup_triangle_helper(bottom_left_positions(), fn cell -> 44 | Cell.set_marble(cell, marble) 45 | end) 46 | |> setup_triangle_helper(top_right_positions(), fn cell -> 47 | Cell.set_target(cell, marble) 48 | end) 49 | end 50 | 51 | def setup_triangle(board, :top_left, marble) do 52 | board 53 | |> setup_triangle_helper(top_left_positions(), fn cell -> 54 | Cell.set_marble(cell, marble) 55 | end) 56 | |> setup_triangle_helper(bottom_right_positions(), fn cell -> 57 | Cell.set_target(cell, marble) 58 | end) 59 | end 60 | 61 | def setup_triangle(board, :top, marble) do 62 | board 63 | |> setup_triangle_helper(top_positions(), fn cell -> 64 | Cell.set_marble(cell, marble) 65 | end) 66 | |> setup_triangle_helper(bottom_positions(), fn cell -> 67 | Cell.set_target(cell, marble) 68 | end) 69 | end 70 | 71 | def setup_triangle(board, :top_right, marble) do 72 | board 73 | |> setup_triangle_helper(top_right_positions(), fn cell -> 74 | Cell.set_marble(cell, marble) 75 | end) 76 | |> setup_triangle_helper(bottom_left_positions(), fn cell -> 77 | Cell.set_target(cell, marble) 78 | end) 79 | end 80 | 81 | def setup_triangle(board, :bottom_right, marble) do 82 | board 83 | |> setup_triangle_helper(bottom_right_positions(), fn cell -> 84 | Cell.set_marble(cell, marble) 85 | end) 86 | |> setup_triangle_helper(top_left_positions(), fn cell -> 87 | Cell.set_target(cell, marble) 88 | end) 89 | end 90 | 91 | def find_winners(board) do 92 | board 93 | |> Enum.filter(fn cell -> 94 | cell.target == cell.marble and cell.marble != nil 95 | end) 96 | |> Enum.group_by(& &1.marble) 97 | |> Map.values() 98 | |> Enum.filter(&(Enum.count(&1) == 10)) 99 | end 100 | 101 | @doc """ 102 | Return a cell from the game board based on pixel coordinates, x and y. 103 | Return nil if the cell does not exist. 104 | 105 | 106 | ## Examples 107 | 108 | iex> get_board_cell(empty(), {17.794, 14.5}) 109 | {:ok, %Sternhalma.Cell{marble: nil, position: %Sternhalma.Hex{x: 3, y: -6, z: 3}}} 110 | 111 | iex> get_board_cell(empty(), {172.794, -104.5}) 112 | {:error, nil} 113 | 114 | 115 | """ 116 | @spec get_board_cell(t(), {number(), number()}) :: {:ok | :error, Cell.t() | nil} 117 | def get_board_cell(board, pixel_coord) do 118 | case Enum.find(board, fn cell -> 119 | cell.position == Hex.from_pixel(pixel_coord) 120 | end) do 121 | nil -> {:error, nil} 122 | board_cell -> {:ok, board_cell} 123 | end 124 | end 125 | 126 | @spec setup_triangle_helper(t(), list(Hex.t()), (Cell.t() -> Cell.t())) :: t() 127 | defp setup_triangle_helper(board, target_positions, func) do 128 | board 129 | |> Enum.map(fn cell -> 130 | if Enum.any?(target_positions, fn position -> 131 | position == cell.position 132 | end) do 133 | func.(cell) 134 | # Cell.set_marble(cell, marble) 135 | else 136 | cell 137 | end 138 | end) 139 | end 140 | 141 | @spec six_point_star() :: list(Hex.t()) 142 | defp six_point_star() do 143 | # left -> right, bottom -> top 144 | [ 145 | {{3, -6, 3}, 1}, 146 | {{2, -5, 3}, 2}, 147 | {{1, -4, 3}, 3}, 148 | {{0, -3, 3}, 4}, 149 | {{-5, -2, 7}, 13}, 150 | {{-5, -1, 6}, 12}, 151 | {{-5, 0, 5}, 11}, 152 | {{-5, 1, 4}, 10}, 153 | {{-5, 2, 3}, 9}, 154 | {{-6, 3, 3}, 10}, 155 | {{-7, 4, 3}, 11}, 156 | {{-8, 5, 3}, 12}, 157 | {{-9, 6, 3}, 13}, 158 | {{-5, 7, -2}, 4}, 159 | {{-5, 8, -3}, 3}, 160 | {{-5, 9, -4}, 2}, 161 | {{-5, 10, -5}, 1} 162 | ] 163 | |> Enum.map(fn {coords, row_length} -> 164 | make_row(coords, row_length) 165 | end) 166 | |> List.flatten() 167 | end 168 | 169 | @spec make_row({number(), number(), number()}, number()) :: list(Hex.t()) 170 | defp make_row({x, z, y}, length) do 171 | [ 172 | Enum.to_list(x..(length + x - 1)), 173 | List.duplicate(z, length), 174 | Enum.to_list(y..(y - (length - 1))) 175 | ] 176 | |> Enum.zip() 177 | |> Enum.map(&Hex.new(&1)) 178 | end 179 | 180 | @doc """ 181 | Return the list of unique marbles found on a game board. 182 | """ 183 | def unique_marbles(board) do 184 | {_, marbles} = 185 | Enum.reduce(board, {%{}, []}, fn cell, {memory, marbles} -> 186 | if cell.marble != nil and Map.get(memory, cell.marble) == nil do 187 | {Map.put(memory, cell.marble, true), [cell.marble | marbles]} 188 | else 189 | {memory, marbles} 190 | end 191 | end) 192 | 193 | marbles 194 | end 195 | 196 | @spec count_marbles(t()) :: number() 197 | def count_marbles(board) do 198 | board 199 | |> unique_marbles() 200 | |> Enum.count() 201 | end 202 | 203 | @spec position_opponent(0..5) :: {:ok, home_triangle()} | {:error, nil} 204 | def position_opponent(0), do: {:ok, :top} 205 | def position_opponent(1), do: {:ok, :bottom} 206 | def position_opponent(2), do: {:ok, :top_left} 207 | def position_opponent(3), do: {:ok, :bottom_right} 208 | def position_opponent(4), do: {:ok, :top_right} 209 | def position_opponent(5), do: {:ok, :bottom_left} 210 | def position_opponent(_), do: {:error, nil} 211 | 212 | defp bottom_positions(), 213 | do: [ 214 | %Hex{x: 3, y: 3, z: -6}, 215 | %Hex{x: 2, y: 3, z: -5}, 216 | %Hex{x: 3, y: 2, z: -5}, 217 | %Hex{x: 1, y: 3, z: -4}, 218 | %Hex{x: 2, y: 2, z: -4}, 219 | %Hex{x: 3, y: 1, z: -4}, 220 | %Hex{x: 0, y: 3, z: -3}, 221 | %Hex{x: 1, y: 2, z: -3}, 222 | %Hex{x: 2, y: 1, z: -3}, 223 | %Hex{x: 3, y: 0, z: -3} 224 | ] 225 | 226 | defp top_positions(), 227 | do: [ 228 | %Hex{x: -5, y: -5, z: 10}, 229 | %Hex{x: -5, y: -4, z: 9}, 230 | %Hex{x: -4, y: -5, z: 9}, 231 | %Hex{x: -5, y: -3, z: 8}, 232 | %Hex{x: -4, y: -4, z: 8}, 233 | %Hex{x: -3, y: -5, z: 8}, 234 | %Hex{x: -5, y: -2, z: 7}, 235 | %Hex{x: -4, y: -3, z: 7}, 236 | %Hex{x: -3, y: -4, z: 7}, 237 | %Hex{x: -2, y: -5, z: 7} 238 | ] 239 | 240 | def bottom_left_positions(), 241 | do: [ 242 | %Hex{x: -5, y: 7, z: -2}, 243 | %Hex{x: -5, y: 6, z: -1}, 244 | %Hex{x: -4, y: 6, z: -2}, 245 | %Hex{x: -5, y: 5, z: 0}, 246 | %Hex{x: -4, y: 5, z: -1}, 247 | %Hex{x: -3, y: 5, z: -2}, 248 | %Hex{x: -5, y: 4, z: 1}, 249 | %Hex{x: -4, y: 4, z: 0}, 250 | %Hex{x: -3, y: 4, z: -1}, 251 | %Hex{x: -2, y: 4, z: -2} 252 | ] 253 | 254 | def top_right_positions(), 255 | do: [ 256 | %Hex{x: 3, y: -9, z: 6}, 257 | %Hex{x: 2, y: -8, z: 6}, 258 | %Hex{x: 3, y: -8, z: 5}, 259 | %Hex{x: 1, y: -7, z: 6}, 260 | %Hex{x: 2, y: -7, z: 5}, 261 | %Hex{x: 3, y: -7, z: 4}, 262 | %Hex{x: 0, y: -6, z: 6}, 263 | %Hex{x: 1, y: -6, z: 5}, 264 | %Hex{x: 2, y: -6, z: 4}, 265 | %Hex{x: 3, y: -6, z: 3} 266 | ] 267 | 268 | def top_left_positions(), 269 | do: [ 270 | %Hex{x: -9, y: 3, z: 6}, 271 | %Hex{x: -8, y: 2, z: 6}, 272 | %Hex{x: -8, y: 3, z: 5}, 273 | %Hex{x: -7, y: 1, z: 6}, 274 | %Hex{x: -7, y: 2, z: 5}, 275 | %Hex{x: -7, y: 3, z: 4}, 276 | %Hex{x: -6, y: 0, z: 6}, 277 | %Hex{x: -6, y: 1, z: 5}, 278 | %Hex{x: -6, y: 2, z: 4}, 279 | %Hex{x: -6, y: 3, z: 3} 280 | ] 281 | 282 | def bottom_right_positions(), 283 | do: [ 284 | %Hex{x: 7, y: -5, z: -2}, 285 | %Hex{x: 6, y: -5, z: -1}, 286 | %Hex{x: 6, y: -4, z: -2}, 287 | %Hex{x: 5, y: -5, z: 0}, 288 | %Hex{x: 5, y: -4, z: -1}, 289 | %Hex{x: 5, y: -3, z: -2}, 290 | %Hex{x: 4, y: -5, z: 1}, 291 | %Hex{x: 4, y: -4, z: 0}, 292 | %Hex{x: 4, y: -3, z: -1}, 293 | %Hex{x: 4, y: -2, z: -2} 294 | ] 295 | end 296 | -------------------------------------------------------------------------------- /test/pathfinding_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PathfindingTest do 2 | use ExUnit.Case, async: true 3 | doctest Sternhalma.Pathfinding, import: true 4 | 5 | alias Sternhalma.{ 6 | Board, 7 | Cell, 8 | Hex, 9 | Pathfinding 10 | } 11 | 12 | defp setup_board(occupied_locations) do 13 | Enum.map(Board.empty(), fn cell -> 14 | if Enum.any?(occupied_locations, fn point -> 15 | cell.position == Hex.from_pixel(point) 16 | end) do 17 | Cell.set_marble(cell, 'a') 18 | else 19 | cell 20 | end 21 | end) 22 | end 23 | 24 | test "finds jumpable neighbors" do 25 | # 26 | # o = empty cell 27 | # x = cell with marble 28 | # s = start 29 | # f = finish 30 | # o 31 | # o o 32 | # o o o 33 | # o o o o 34 | # o o o o o o o o o o o o o 35 | # o o o o o o o o o o o o 36 | # o o o o o o o o o o o 37 | # o o o x o o o o o o 38 | # o x x s x o o o o 39 | # o o o o o o o o o o 40 | # o o o o o o o o o o o 41 | # o o o o o o o o o o o o 42 | # o o o o o o o o o o o o o 43 | # o o o o 44 | # o o o 45 | # o o 46 | # o 47 | # 48 | 49 | start = %Cell{marble: 'a', position: Hex.from_pixel({8.268, 13})} 50 | 51 | board = 52 | setup_board([ 53 | {10, 13}, 54 | {6.536, 13}, 55 | {4.804, 13}, 56 | {7.402, 14.5} 57 | ]) 58 | 59 | assert Pathfinding.jumpable_neighbors(start.position, board) == [ 60 | %Cell{marble: nil, position: Hex.from_pixel({6.536, 16})}, 61 | %Cell{marble: nil, position: Hex.from_pixel({11.732, 13})} 62 | ] 63 | end 64 | 65 | test "finds path to a neighboring cell" do 66 | # 67 | # o = empty cell 68 | # x = cell with marble 69 | # s = start 70 | # f = finish 71 | # o 72 | # o o 73 | # o o o 74 | # o o o o 75 | # o o o o o o o o o o o o o 76 | # o o o o o o o o o o o o 77 | # o o o o o o o o o o o 78 | # o o o o o o o o o o 79 | # o o o o o o o o o 80 | # o o o o o o o o o o 81 | # o o o o o o o o o o o 82 | # o o o o o o o o o o o o 83 | # o o o o o o o o o o o o o 84 | # o s o o 85 | # o f o 86 | # o o 87 | # o 88 | # 89 | 90 | start = %Cell{marble: 'a', position: Hex.from_pixel({9.134, 5.5})} 91 | finish = %Cell{position: Hex.from_pixel({10, 4})} 92 | board = setup_board([]) 93 | 94 | assert Pathfinding.path(board, start, finish) == [ 95 | start, 96 | finish 97 | ] 98 | end 99 | 100 | test "does not find path to a distant cell" do 101 | start = %Cell{marble: 'a', position: Hex.from_pixel({10, 1})} 102 | finish = %Cell{position: Hex.from_pixel({8.268, 4})} 103 | board = setup_board([{10, 1}]) 104 | 105 | assert Pathfinding.path(board, start, finish) == [] 106 | end 107 | 108 | test "does not find path to the same cell" do 109 | start = %Cell{marble: 'a', position: Hex.from_pixel({9.134, 5.5})} 110 | board = setup_board([{9.134, 5.5}]) 111 | 112 | assert Pathfinding.path(board, start, start) == [] 113 | end 114 | 115 | test "does not find path when the starting cell does not have a marble" do 116 | start = %Cell{marble: nil, position: Hex.from_pixel({9.134, 5.5})} 117 | finish = %Cell{position: Hex.from_pixel({10, 4})} 118 | board = setup_board([]) 119 | 120 | assert Pathfinding.path(board, start, finish) == [] 121 | end 122 | 123 | test "finds a path by jumping in a straight line", _state do 124 | # 125 | # o = empty cell 126 | # x = cell with marble 127 | # s = start 128 | # f = finish 129 | # o 130 | # o o 131 | # o o o 132 | # o o o o 133 | # o o o o o o o o o o o o o 134 | # o o o o o o o o o o o o 135 | # o o o o o o o o o o o 136 | # o o o o o o o o o o 137 | # o o o o o o o o o 138 | # o o o o o o o o o o 139 | # o o f o o o o o o o o 140 | # o o o x o o o o o o o o 141 | # o o o o o o o o o o o o o 142 | # x o o o 143 | # o o o 144 | # x o 145 | # s 146 | # 147 | 148 | start = %Cell{marble: 'a', position: Hex.from_pixel({10, 1})} 149 | finish = %Cell{position: Hex.from_pixel({4.804, 10})} 150 | board = setup_board([{9.134, 2.5}, {7.402, 5.5}, {5.67, 8.5}]) 151 | 152 | assert Pathfinding.path(board, start, finish) == [ 153 | start, 154 | %Cell{position: Hex.from_pixel({8.268, 4})}, 155 | %Cell{position: Hex.from_pixel({6.536, 7})}, 156 | finish 157 | ] 158 | end 159 | 160 | test "finds a path by jumping and changing directions", _state do 161 | # 162 | # o = empty cell 163 | # x = cell with marble 164 | # s = start 165 | # f = finish 166 | # o 167 | # o o 168 | # o o o 169 | # o o o o 170 | # o o o o o o o o o o o o o 171 | # o o o o o o o o o o o o 172 | # o o o o o o o o o o o 173 | # o o o o o o o o o o 174 | # o o o o o o o o o 175 | # o o o o o o o o o o 176 | # o o o o o o o o o o o 177 | # o o o o o o o o o o o o 178 | # o o o o o o f x o o o o o 179 | # o o o x 180 | # o x o 181 | # x o 182 | # s 183 | # 184 | 185 | start = %Cell{marble: 'a', position: Hex.from_pixel({10, 1})} 186 | finish = %Cell{position: Hex.from_pixel({10, 7})} 187 | board = setup_board([{9.134, 2.5}, {10, 4}, {12.598, 5.5}, {11.732, 7}]) 188 | 189 | assert Pathfinding.path(board, start, finish) == [ 190 | start, 191 | %Cell{position: Hex.from_pixel({8.268, 4})}, 192 | %Cell{position: Hex.from_pixel({11.732, 4})}, 193 | %Cell{position: Hex.from_pixel({13.464, 7})}, 194 | finish 195 | ] 196 | end 197 | 198 | test "does not find path when jump not possible", _state do 199 | # 200 | # o = empty cell 201 | # x = cell with marble 202 | # s = start 203 | # f = finish 204 | # o 205 | # o o 206 | # o o o 207 | # o o o o 208 | # o o o o o o o o o o o o o 209 | # o o o o o o o o o o o o 210 | # o o o o o o o o o o o 211 | # o o o o o o o o o o 212 | # o o o o o o o o o 213 | # o o o o o o o o o o 214 | # o o o o o o o o o o o 215 | # o o o o o o o o o o o o 216 | # o o o o o o o o o o o o o 217 | # o o o o 218 | # o s o 219 | # x x 220 | # f 221 | # 222 | 223 | start = %Cell{marble: 'a', position: Hex.from_pixel({10, 4})} 224 | finish = %Cell{position: Hex.from_pixel({10, 1})} 225 | board = setup_board([{10, 4}, {9.134, 2.5}, {10.866, 2.5}]) 226 | 227 | assert Pathfinding.path(board, start, finish) == [] 228 | end 229 | 230 | test "does not find path when finishing cell is occupied", _state do 231 | # 232 | # o = empty cell 233 | # x = cell with marble 234 | # s = start 235 | # f = finish 236 | # o 237 | # o o 238 | # o o o 239 | # o o o o 240 | # o o o o o o o o o o o o o 241 | # o o o o o o o o o o o o 242 | # o o o o o o o o o o o 243 | # o o o o o o o o o o 244 | # o o o o o o o o o 245 | # o o o o o o o o o o 246 | # o o o o o o o o o o o 247 | # o o o o o o o o o o o o 248 | # o o o o o f o o o o o o o 249 | # o x o o 250 | # o s o 251 | # o o 252 | # o 253 | # 254 | 255 | start = %Cell{marble: 'a', position: Hex.from_pixel({10, 4})} 256 | finish = %Cell{position: Hex.from_pixel({8.268, 7})} 257 | board = setup_board([{9.134, 5.5}, {8.268, 7}]) 258 | 259 | assert Pathfinding.path(board, start, finish) == [] 260 | end 261 | 262 | test "(11.7, 4) -> (10, 7) is valid", _state do 263 | # 264 | # o = empty cell 265 | # x = cell with marble 266 | # s = start 267 | # f = finish 268 | # o 269 | # o o 270 | # o o o 271 | # o o o o 272 | # o o o o o o o o o o o o o 273 | # o o o o o o o o o o o o 274 | # o o o o o o o o o o o 275 | # o o o o o o o o o o 276 | # o o o o o o o o o 277 | # o o o o o o o o o o 278 | # o o o o o o o o o o o 279 | # o o o o o o o o o o o o 280 | # o o o o o o f o o o o o o 281 | # o o x x 282 | # o o s 283 | # o o 284 | # o 285 | # 286 | 287 | start = %Cell{marble: 'a', position: Hex.from_pixel({11.732, 4})} 288 | finish = %Cell{position: Hex.from_pixel({10, 7})} 289 | board = setup_board([{12.5, 5.5}, {10.866, 5.5}]) 290 | 291 | assert Pathfinding.path(board, start, finish) == [ 292 | start, 293 | finish 294 | ] 295 | end 296 | 297 | test "does not get stuck when there is a circular dependency", _state do 298 | # 299 | # o = empty cell 300 | # x = cell with marble 301 | # s = start 302 | # f = finish 303 | # o 304 | # o o 305 | # o o o 306 | # o o o o 307 | # o o o o o o o o o o o o o 308 | # o o o o o o o o o o o o 309 | # o o o o o o o o o o o 310 | # o o s x o o o o f o 311 | # o o o x x x x x o 312 | # o o o o x o o o o o 313 | # o o o o x x o o o o o 314 | # o o o o o o o o o o o o 315 | # o o o o o o o o o o o o o 316 | # o o o o 317 | # o o o 318 | # o o 319 | # o 320 | # 321 | 322 | start = %Cell{marble: 'a', position: Hex.from_pixel({5.67, 14.5})} 323 | finish = %Cell{position: Hex.from_pixel({16.062, 14.5})} 324 | 325 | board = 326 | setup_board([ 327 | {7.402, 14.5}, 328 | {8.268, 13}, 329 | {10, 13}, 330 | {11.732, 13}, 331 | {13.464, 13}, 332 | {15.196, 13}, 333 | {9.134, 11.5}, 334 | {10, 10}, 335 | {8.268, 10} 336 | ]) 337 | 338 | path = Pathfinding.path(board, start, finish) 339 | 340 | assert path == [ 341 | start, 342 | %Cell{marble: nil, position: Hex.from_pixel({9.134, 14.5})}, 343 | %Cell{marble: nil, position: Hex.from_pixel({10.866, 11.5})}, 344 | %Cell{marble: nil, position: Hex.from_pixel({12.598, 14.5})}, 345 | %Cell{marble: nil, position: Hex.from_pixel({14.33, 11.5})}, 346 | finish 347 | ] 348 | end 349 | end 350 | --------------------------------------------------------------------------------