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
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)
if card do
play(game, card)
else
{:error, :invalid_card}
end
end
def play(game, %Card{} = card) do
if card in Enum.at(game.players, game.action_on).hand do
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}
else
{:error, :invalid_card}
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, &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
def take_trick(player, trick) do
%Player{player | pile: player.pile ++ trick}
end
@spec trump_suit(t()) :: Card.suit()
def trump_suit(game), do: game.briscola.suit
@spec lead_suit(t()) :: Card.suit()
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)
end
@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)
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