defmodule Briscola.Game do @moduledoc """ `Briscola.Game` module implements a struct that represents a game state, and functions to manipulate the game state according to the stages of the game. """ alias Briscola.Game alias Briscola.Card alias Briscola.Deck alias Briscola.Player @hand_size 3 defstruct [:deck, :players, :briscola, :trick, :action_on] @type t() :: %__MODULE__{ deck: Deck.t(), players: [Player.t()], briscola: Card.t(), trick: [Card.t()], action_on: integer() } @typedoc """ Options for creating a new game. Players is the number of players, can be 2 or 4. Goes first is the index of the player who goes first (zero indexed) """ @type new_game_options() :: [players: 2 | 4, goes_first: non_neg_integer()] @doc """ Create a new game of Briscola. """ @spec new(new_game_options()) :: t() def new(opts \\ []) do player_count = case Keyword.get(opts, :players, 2) do i when i in [2, 4] -> i i -> raise ArgumentError, "Invalid number of players: #{i}" end goes_first = case Keyword.get(opts, :goes_first, 0) do i when i < 0 or i >= player_count -> raise ArgumentError, "Invalid first player: #{i}" i -> i end {deck, [briscola]} = Deck.new() |> Deck.shuffle() |> Deck.take(1) %Briscola.Game{ deck: deck, players: List.duplicate(%Player{hand: [], pile: []}, player_count), briscola: briscola, trick: [], action_on: goes_first } |> deal_cards(@hand_size) end @typedoc """ Possible errors for the `play/2` function. """ @type play_error() :: :trick_over | :must_redeal | :invalid_card @doc """ Play a card for the current player. Returns an error if the trick is over, or the card is not valid. Cards can be played by the index of the player's hand or by a Card struct. """ @spec play(t(), Card.t() | non_neg_integer()) :: {:ok, t()} | {:error, play_error()} def play(game, card) def play(game, _) when length(game.trick) == length(game.players) do {:error, :trick_over} end def play(game, card_index) when is_integer(card_index) do card = Enum.at(game.players, game.action_on).hand |> Enum.at(card_index) play_card(game, card) end def play(game, card) when is_struct(card), do: play_card(game, card) @spec play_card(t(), Card.t() | nil) :: {:ok, t()} | {:error, play_error()} defp play_card(%Game{} = game, card) do case playable(game, card) do :ok -> new_players = List.update_at(game.players, game.action_on, &Player.remove_from_hand(&1, card)) game = %Briscola.Game{ game | trick: [card | game.trick], action_on: rem(game.action_on + 1, length(game.players)), players: new_players } {:ok, game} err -> {:error, err} end end @spec playable(t(), Card.t() | nil) :: play_error() | :ok defp playable(%Game{} = game, card) do cond do should_score_trick?(game) -> :trick_not_over needs_redeal?(game) -> :must_redeal card == nil -> :invalid_card card not in Enum.at(game.players, game.action_on).hand -> :invalid_card true -> :ok end end @doc """ Score the current trick, moving the cards to the winning player's pile. Also clears the trick and sets the action to the winning player. """ @spec score_trick(t()) :: {:ok, t(), non_neg_integer()} | {:error, :trick_not_over} def score_trick(game) def score_trick(%Game{} = game) when length(game.trick) != length(game.players) do {:error, :trick_not_over} end def score_trick(game) do {winning_player, _winning_card} = trick_winner(game) game = %Briscola.Game{ game | players: List.update_at(game.players, winning_player, &Player.take_trick(&1, game.trick)), trick: [], action_on: winning_player } {:ok, game, winning_player} end defp trick_winner(game) do trump = trump_suit(game) lead = lead_suit(game) winning_card = Enum.reduce(game.trick, nil, fn card, best -> cond do best == nil -> card card.suit == trump && best.suit != trump -> card card.suit == lead && best.suit != trump && best.suit != lead -> card card.suit == trump && best.suit == trump && Card.strength(card) > Card.strength(best) -> card card.suit == lead && best.suit == lead && Card.strength(card) > Card.strength(best) -> card true -> best end end) winning_card_index = Enum.reverse(game.trick) |> Enum.find_index(&(&1 == winning_card)) # Work backwards to find index of winning player. Action should be on the original player after a full trick. winning_player_index = abs(rem(game.action_on + winning_card_index, length(game.players))) {winning_player_index, winning_card} end @doc """ Redistribute one card to each player, call this after scoring a trick. Since the last few turns of the game don't have enough cards to deal to all players, it's normal to get back {:error, :not_enough_cards} on the last few turns. """ @spec redeal(t()) :: t() | {:error, :trick_not_scored | :not_enough_cards} def redeal(game) def redeal(game) when length(game.trick) != 0 do {:error, :trick_not_scored} end def redeal(game) when length(game.deck.cards) + 1 < length(game.players) do {:error, :not_enough_cards} end # Deal the briscola to the last player def redeal(game) when length(game.deck.cards) + 1 == length(game.players) do game = %Briscola.Game{ game | deck: %Deck{game.deck | cards: game.deck.cards ++ [game.briscola]} } deal_cards(game, 1) end def redeal(game) when length(game.trick) == 0 do if Enum.all?(game.players, fn p -> length(p.hand) < 3 end) do deal_cards(game, 1) else {:error, :players_have_cards} end end defp deal_cards(%Game{} = game, n) do {new_deck, cards} = Deck.take(game.deck, n * length(game.players)) # "Rotate" the list so the winner of the last trick gets the first # batch of cards, and the player fursthest from the action gets the last. # This ensures the last player gets the briscola on the last dealing. new_players = Enum.split(game.players, game.action_on) |> Tuple.to_list() |> Enum.reverse() |> Enum.concat() |> Enum.zip(Enum.chunk_every(cards, n)) |> Enum.map(fn {player, new_cards} -> %Player{player | hand: player.hand ++ new_cards} end) # Rotate the list back so players are in their original positions. |> Enum.split(length(game.players) - game.action_on) |> Tuple.to_list() |> Enum.reverse() |> Enum.concat() %Game{game | deck: new_deck, players: new_players} end @doc """ Check if we should redeal, i.e. the trick has been scored, players need cards, and the deck has enough cards to deal to each player. """ @spec needs_redeal?(t()) :: boolean() def needs_redeal?(%Game{} = game) do length(game.trick) == 0 and length(game.deck.cards) > 0 and Enum.all?(game.players, &(length(&1.hand) < @hand_size)) end @doc """ Retrieve the trump suit of the current game (the suit of the briscola card). """ @spec trump_suit(t()) :: Card.suit() def trump_suit(game), do: game.briscola.suit @doc """ Retrieve the lead suit of the current trick. If there are no cards in the trick, return `nil`. Otherwise, return the suit of the first card played in the trick. """ @spec lead_suit(t()) :: Card.suit() | nil def lead_suit(game) when length(game.trick) == 0, do: nil def lead_suit(game) when length(game.trick) > 0, do: List.last(game.trick).suit @doc """ Check if the trick is over, i.e. all players have played a card. """ @spec should_score_trick?(t()) :: boolean() def should_score_trick?(%Game{} = game), do: length(game.trick) == length(game.players) @doc """ Check if the game is over, i.e. the deck is empty and all players have no cards. """ @spec game_over?(t()) :: boolean() def game_over?(%Game{} = game), do: length(game.deck.cards) == 0 and Enum.all?(game.players, fn p -> length(p.hand) == 0 end) @doc """ Return players in order of highest score to lowest. """ @spec leaders(t()) :: [Player.t()] def leaders(%Game{} = game) do Enum.with_index(game.players) |> Enum.sort_by(fn {player, _idx} -> Player.score(player) end, &>=/2) |> Enum.map(&elem(&1, 1)) end end