Packages

Briscola card game, rules, and simulation utilities.

Current section

Files

Jump to
briscola lib game.ex
Raw

lib/game.ex

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