lib/tabletop.ex

defmodule Tabletop do
  @moduledoc """
  Tabletop contains functions for playing a board game and querying the state of the game.

  ## Taking Turns

  Taking a turn involves calling the `take_turn/2` function which follows these steps:

  1. Apply provided actions - e.g. add a piece, move a piece
  2. Apply board effects - e.g. check if a player has won
  3. Increment the turn counter

  To find out more about actions or effects, check the `Tabletop.Actions` or `Tabletop.Board`
  modules respectively.
  """

  alias Tabletop.Board

  @doc """
  Applies the provided `actions` to the board and then advances to the next turn.
  Actions consist of a combination of the following:

    * `:move   => {from, to}`
    * `:add    => {piece, position}`
    * `:remove => position`
    * `:assign => {position, attributes}`

  For example, a Chess turn might see a single `:move` action or two `:move` actions in the
  case of castling.

  ## Examples

      iex> %Tabletop.Board{}
      iex>   |> Tabletop.take_turn(%{})
      %Tabletop.Board{turn: 2}

      iex> Tabletop.Board.square(3)
      iex>   |> Tabletop.take_turn(add: {Tabletop.Piece.new("Rook"), {0, 0}})
      iex>   |> Tabletop.take_turn(move: {{0, 0}, {0, 1}})
      iex>   |> Tabletop.get_piece({0, 1})
      %Tabletop.Piece{id: "Rook"}

  """
  def take_turn(board, actions) do
    board
      |> Board.apply_actions(actions)
      |> Board.apply_effects()
      |> Board.advance_turn()
  end

  @spec get_piece(atom | %{:pieces => map, optional(any) => any}, any) :: any
  @doc """
  Returns the piece on the `board` at `position`.

  ## Examples

      iex> Tabletop.Board.square(3)
      iex>   |> Tabletop.Actions.apply(:add,  {Tabletop.Piece.new("Rook"), {0, 0}})
      iex>   |> Tabletop.get_piece({0, 0})
      %Tabletop.Piece{id: "Rook"}

      iex> Tabletop.Board.square(3)
      iex>   |> Tabletop.get_piece({1, 0})
      nil

  """
  def get_piece(%Board{pieces: pieces}, position) do
    Map.get(pieces, position)
  end

  @doc """
  Checks if the provided `position` on the `board` is occupied by a piece.

  ## Examples

      iex> Tabletop.Board.square(3)
      iex>   |> Tabletop.Actions.apply(:add, {Tabletop.Piece.new("Rook"), {0, 0}})
      iex>   |> Tabletop.occupied?({0, 0})
      true

  """
  def occupied?(board, position) do
    get_piece(board, position) != nil
  end

  @doc """
  Checks if the provided `position` is within the bounds of the `board`.

  ## Examples

      iex> Tabletop.Board.square(3)
      iex>   |> Tabletop.in_bounds?({0, 0})
      true

      iex> Tabletop.Board.square(3)
      iex>   |> Tabletop.in_bounds?({99, 99})
      false

  """
  def in_bounds?(%Board{pieces: pieces}, position) do
    Map.has_key?(pieces, position)
  end

  @doc """
  Finds the position of the first matching `piece` on the `board`. If no piece is
  found, `default` is returned.

  ## Examples

      iex> Tabletop.Board.square(3)
      iex>   |> Tabletop.position_of(Tabletop.Piece.new("Pawn"), :unknown)
      :unknown

      iex> piece = Tabletop.Piece.new("Pawn")
      iex> Tabletop.Board.square(3)
      iex>   |> Tabletop.Actions.apply(:add, {piece, {0, 0}})
      iex>   |> Tabletop.position_of(piece)
      {0,0}

  """
  def position_of(%Tabletop.Board{pieces: pieces}, piece, default \\ nil) do
    Map.keys(pieces)
      |> Enum.find(default, fn pos ->
        Tabletop.Piece.equal?(piece, Map.get(pieces, pos))
      end)
  end

  @doc """
  Lazily moves through positions on the board starting from `starting_position`. Each element
  returned be a Tuple containing the position and piece at that position.

  Invokes `fun` with the current position in order to determine the next position.

  If the position does not contain a piece, the second element of the returned
  Tuple will be `nil` instead.

  Once the position is out of bounds, no more elements will be returned.

  ## Examples

      iex> Tabletop.Board.square(3)
      iex>   |> Tabletop.travel({0, 0}, fn {x, y} -> {x + 1, y + 1} end)
      iex>   |> Enum.to_list()
      [{{0, 0}, nil}, {{1, 1}, nil}, {{2, 2}, nil}]

  """
  def travel(board, starting_position, fun) do
    Stream.unfold(starting_position, fn pos ->
      if in_bounds?(board, pos), do: {pos, fun.(pos)}, else: nil
    end)
      |> Stream.map(fn pos -> {pos, get_piece(board, pos)} end)
  end

  @doc """
  Lazily moves through positions on the board starting from `starting_position` and
  returns sets of neighbouring positions. Each element will be a tuple containing
  two positions.

  Invokes `fun` with the current position in order to determine the positions of
  neighbours.

  Once every set of neighbouring positions are covered, no more elements will be returned.

  ## Examples

      iex> Tabletop.Board.square(3)
      iex>   |> Tabletop.neighbours({0, 0}, &Tabletop.Grid.cardinal_points/1)
      iex>   |> Enum.take(2)
      [{{0, 0}, {1, 0}}, {{0, 0}, {0, 1}}]

  """
  def neighbours(board, starting_position, fun) do
    Stream.unfold({[starting_position], MapSet.new()}, fn
      {[head | tail], checked} ->
        neighbouring_positions = fun.(head)
          |> Stream.filter(fn pos -> in_bounds?(board, pos) end)
          |> Enum.reject(fn pos -> MapSet.member?(checked, pos) end)
        pairs = Stream.map(neighbouring_positions, fn pos -> {head, pos} end)
        {pairs, {Enum.uniq(neighbouring_positions ++ tail), MapSet.put(checked, head)}}
      _ ->
        nil
    end)
      |> Stream.flat_map(fn item -> item end)
  end

end