Packages

Perudex is a library implementing the game Perudo, also known as Dudo or Liar's Dice (which you may have heard of in the Pirates of the Caribbean movie).

Current section

Files

Jump to
perudex lib game.ex
Raw

lib/game.ex

defmodule Perudex.Game do
@moduledoc """
Provides functions to manipulate a game of Perudex.
"""
alias __MODULE__
alias Perudex.Hand
defstruct [
:current_player_id,
:all_players,
:current_bid,
:max_dice,
:instructions,
players_hands: %{},
phase: :normal,
old_phase: :normal
]
@opaque t :: %Game{
phase: game_phase,
old_phase: game_phase,
current_player_id: player_id,
all_players: [player_id],
current_bid: bid,
players_hands: %{player_id: Hand.t()},
max_dice: integer(),
instructions: [instruction]
}
@type player_id :: any
@type move :: {:outbid, bid} | :calza | :dudo
@type game_phase :: :normal | :palifico
@type instruction :: {:notify_player, player_id, player_instruction}
@type bid :: {:count, :die}
@type move_result :: {:outbid, bid} | {:calza, boolean} | {:dudo, boolean}
@type player_instruction ::
{:move, Hand.t()}
| {:reveal_players_hands, %{player_id() => Hand.t()}, {integer, integer}}
| {:last_move, player_id, move_result}
| :unauthorized_move
| :invalid_bid
| :illegal_move
| {:new_hand, Hand.t()}
| {:winner, player_id}
| {:loser, player_id}
| {:game_started, [player_id]}
| {:phase_change, game_phase}
@doc """
Initialize a game of Perudo with `players_ids` and specified `max_dice` a player can hold.
Returns a tuple containing a list of `Perudex.Game.player_instruction()` and a `Perudex.Game` struct.
## Examples
iex>
:rand.seed(:exsplus, {101, 102, 103})
Perudex.Game.start([1, 2], 5)
{[
{:notify_player, 1, {:game_started, [1, 2]}},
{:notify_player, 2, {:game_started, [1, 2]}},
{:notify_player, 1, {:new_hand, %Perudex.Hand{dice: [5, 5, 2, 6, 4], remaining_dice: 5}}},
{:notify_player, 2, {:new_hand, %Perudex.Hand{dice: [1, 3, 6, 4, 2], remaining_dice: 5}}},
{:notify_player, 1, {:move, %Perudex.Hand{dice: [5, 5, 2, 6, 4]}}
],
%Perudex.Game{
all_players: [1, 2],
current_bid: {0, 0},
current_player_id: 1,
instructions: [],
max_dice: 5,
players_hands: %{1 => hand: %Perudex.Hand{dice: [5, 5, 2, 6, 4], remaining_dice: 5}, 2 => %Perudex.Hand{dice: [1, 3, 6, 4, 2], remaining_dice: 5}}
}}
"""
@spec start([player_id], integer) :: {[player_instruction], Perudex.Game.t()}
def start(player_ids, max_dice) do
%Game{
current_player_id: hd(player_ids),
all_players: player_ids,
players_hands:
Map.new(player_ids, fn id -> {id, Hand.new(%Hand{remaining_dice: max_dice})} end),
max_dice: max_dice,
instructions: []
}
|> notify_players({:game_started, player_ids})
|> start_round()
|> instructions_and_state()
end
@doc """
Play a Perudo `move` on the current game.
A move can either be an outbid, a calza (exactly the same amount of dice as the previous bid) or a dudo (bid is too ambitious).
## Examples
iex> Perudex.Game.play_move(
...> %Perudex.Game{
...> all_players: [1, 2],
...> current_bid: {2, 3},
...> current_player_id: 2,
...> instructions: [],
...> max_dice: 5,
...> players_hands: %{1 => %Perudex.Hand{dice: [2, 4, 2, 5, 6], remaining_dice: 5}, 2 => %Perudex.Hand{dice: [1, 3, 4, 4, 5], remaining_dice: 5}}},
...> 1,
...> {:outbid, {2, 3}})
{[
{:notify_player, 1, {:last_move, 1, {:outbid, {2, 3}}}},
{:notify_player, 2, {:last_move, 1, {:outbid, {2, 3}}}},
{:notify_player, 2, :move}
],
%Perudex.Game{
all_players: [1, 2],
current_bid: {2, 3},
current_player_id: 2,
instructions: [],
max_dice: 5,
players_hands: %{1 => %Perudex.Hand{dice: [2, 4, 2, 5, 6], remaining_dice: 5}, 2 => %Perudex.Hand{dice: [1, 3, 4, 4, 5], remaining_dice: 5}}
}}
"""
@spec play_move(t, player_id, move) :: {[instruction], t()}
def play_move(%Game{current_player_id: player_id} = game, player_id, move),
do: handle_move(%Game{game | instructions: []}, move)
def play_move(game, player_id, _move) do
%Game{game | instructions: []}
|> notify_player(player_id, :unauthorized_move)
|> take_instructions()
end
defp handle_move(game, {:outbid, bid} = move) do
case outbid(game, bid) do
{:ok, game} ->
game
|> notify_players({:last_move, game.current_player_id, move})
|> find_next_player()
|> instructions_and_state()
{:error, game} ->
game
|> notify_player(game.current_player_id, :invalid_bid)
|> take_instructions()
end
end
defp handle_move(%Game{current_player_id: move_initiator} = game, :calza) do
case calza(game) do
{:ok, game, success_status} ->
end_round(game, {:last_move, move_initiator, {:calza, success_status}})
{:error, game} ->
game
|> notify_player(game.current_player_id, :illegal_move)
|> take_instructions()
end
end
defp handle_move(%Game{current_player_id: move_initiator} = game, :dudo) do
case dudo(game) do
{:ok, game, success_status} ->
end_round(game, {:last_move, move_initiator, {:dudo, success_status}})
{:error, game} ->
game
|> notify_player(game.current_player_id, :illegal_move)
|> take_instructions()
end
end
defp dudo(%Game{current_bid: {0, 0}} = game), do: {:error, game}
defp dudo(
%Game{
players_hands: players_hands,
current_bid: {current_count, _},
phase: current_phase
} = game
) do
current_count_frequency = get_current_die_frequency(game)
round_loser = find_dudo_loser(game, current_count_frequency)
%Hand{has_palificoed: has_already_used_palifico} = players_hands[round_loser]
updated_hand = Hand.take(players_hands[round_loser])
phase =
if updated_hand.has_palificoed and not has_already_used_palifico,
do: :palifico,
else: :normal
{:ok,
%Game{
game
| current_player_id: round_loser,
players_hands: %{
players_hands
| round_loser => updated_hand
},
phase: phase,
old_phase: current_phase
}, current_count_frequency < current_count}
end
defp calza(%Game{current_bid: {0, 0}} = game), do: {:error, game}
defp calza(
%Game{
players_hands: players_hands,
current_bid: {current_count, _},
current_player_id: current_player,
phase: current_phase
} = game
) do
current_count_frequency = get_current_die_frequency(game)
%Hand{has_palificoed: has_already_used_palifico} = players_hands[current_player]
updated_hand =
if current_count_frequency == current_count do
Hand.add(players_hands[current_player])
else
Hand.take(players_hands[current_player])
end
phase =
if updated_hand.has_palificoed and not has_already_used_palifico,
do: :palifico,
else: :normal
{:ok,
%Game{
game
| players_hands: %{players_hands | current_player => updated_hand},
phase: phase,
old_phase: current_phase
}, current_count_frequency == current_count}
end
defp outbid(game, {count, dice}) when not is_integer(dice) or not is_integer(count),
do: {:error, game}
defp outbid(%Game{current_bid: {0, 0}, phase: :normal} = game, {_new_count, 1}),
do: {:error, game}
defp outbid(%Game{current_bid: {count, dice}} = game, {count, dice}), do: {:error, game}
defp outbid(game, {_, dice}) when dice > 6, do: {:error, game}
defp outbid(game, {count, dice}) when dice < 1 or count < 1, do: {:error, game}
defp outbid(%Game{current_bid: {0, 0}, phase: :palifico} = game, {new_count, new_value}),
do: {:ok, %Game{game | instructions: [], current_bid: {new_count, new_value}}}
defp outbid(%Game{current_bid: {old_count, value}, phase: :palifico} = game, {new_count, value})
when new_count > old_count,
do: {:ok, %Game{game | instructions: [], current_bid: {new_count, value}}}
defp outbid(%Game{current_bid: {current_count, 1}} = game, {new_count, 1})
when new_count <= current_count,
do: {:error, game}
defp outbid(%Game{current_bid: {current_count, _}} = game, {new_count, 1})
when new_count < ceil(current_count / 2),
do: {:error, game}
defp outbid(%Game{current_bid: {_current_count, _}} = game, {new_count, 1}),
do: {:ok, %Game{game | instructions: [], current_bid: {new_count, 1}}}
defp outbid(%Game{current_bid: {current_count, 1}} = game, {new_count, _})
when new_count < current_count * 2 + 1,
do: {:error, game}
defp outbid(%Game{current_bid: {_current_count, 1}} = game, {new_count, new_dice}),
do: {:ok, %Game{game | instructions: [], current_bid: {new_count, new_dice}}}
defp outbid(%Game{current_bid: {current_count, current_dice}} = game, {new_count, new_dice})
when (new_count < current_count or new_dice <= current_dice) and
(new_count <= current_count or new_dice < current_dice),
do: {:error, game}
defp outbid(%Game{} = game, {new_count, new_dice}),
do: {:ok, %Game{game | instructions: [], current_bid: {new_count, new_dice}}}
defp find_dudo_loser(
%Game{current_player_id: current_player, current_bid: {current_count, _}} = game,
current_count_frequency
) do
previous_player = find_previous_player(game)
if current_count_frequency < current_count do
previous_player
else
current_player
end
end
defp end_round(game, move_result) do
game
|> notify_players(move_result)
|> reveal_players_hands()
|> check_for_loser()
|> start_round()
|> instructions_and_state()
end
defp reveal_players_hands(%Game{players_hands: hands, current_bid: {_, die}} = game),
do:
notify_players(game, {:reveal_players_hands, hands, {get_current_die_frequency(game), die}})
defp find_next_player(%Game{players_hands: players} = game) when map_size(players) == 1 do
{id, _} = Enum.at(players, 0)
%Game{game | current_player_id: id}
end
defp find_next_player(game) do
current_player_index =
Enum.find_index(game.players_hands, fn {id, _} -> id == game.current_player_id end)
{next_player_id, _} =
Enum.at(game.players_hands, current_player_index + 1, Enum.at(game.players_hands, 0))
%Game{game | current_player_id: next_player_id}
end
defp find_previous_player(game) do
current_player_index =
Enum.find_index(game.players_hands, fn {id, _} -> id == game.current_player_id end)
{id, _} =
Enum.at(
game.players_hands,
current_player_index - 1,
Enum.at(game.players_hands, Enum.count(game.players_hands) - 1)
)
id
end
defp check_for_loser(%Game{} = game) do
loser = Enum.find(game.players_hands, fn {_, hand} -> hand.remaining_dice == 0 end)
case loser do
nil ->
game
{loser_id, _} ->
game
|> find_next_player()
|> eliminate_player(loser_id)
|> notify_players({:loser, loser_id})
end
end
defp eliminate_player(%Game{} = game, loser_id) do
%Game{
game
| players_hands: Map.delete(game.players_hands, loser_id)
}
end
defp get_current_die_frequency(%Game{
players_hands: players_hands,
current_bid: {_, 1}
}) do
dice_frequencies = get_dice_frequencies(players_hands)
dice_frequencies =
if dice_frequencies[1] == nil,
do: Map.put(dice_frequencies, 1, 0),
else: dice_frequencies
dice_frequencies[1]
end
defp get_current_die_frequency(%Game{
players_hands: players_hands,
current_bid: {_, current_die},
phase: :normal
}) do
dice_frequencies = get_dice_frequencies(players_hands)
dice_frequencies =
if dice_frequencies[current_die] == nil,
do: Map.put(dice_frequencies, current_die, 0),
else: dice_frequencies
dice_frequencies =
if dice_frequencies[1] == nil,
do: Map.put(dice_frequencies, 1, 0),
else: dice_frequencies
dice_frequencies[current_die] + dice_frequencies[1]
end
defp get_current_die_frequency(%Game{
players_hands: players_hands,
current_bid: {_, current_die},
phase: :palifico
}) do
dice_frequencies = get_dice_frequencies(players_hands)
dice_frequencies =
if dice_frequencies[current_die] == nil,
do: Map.put(dice_frequencies, current_die, 0),
else: dice_frequencies
dice_frequencies[current_die]
end
defp get_dice_frequencies(players_hands) do
players_hands
|> Enum.flat_map(fn {_, hand} -> hand.dice end)
|> Enum.frequencies()
end
defp notify_player(game, player_id, data) do
%Game{
game
| instructions: [{:notify_player, player_id, data} | game.instructions]
}
end
defp notify_players(game, data) do
Enum.reduce(
game.all_players,
game,
&notify_player(
&2,
&1,
data
)
)
end
defp instructions_and_state(game) do
game
|> tell_current_player_to_move()
|> take_instructions()
end
defp tell_current_player_to_move(%Game{current_player_id: nil} = game), do: game
defp tell_current_player_to_move(%Game{current_player_id: id, players_hands: hands} = game),
do: notify_player(game, id, {:move, hands[id]})
defp start_round(%Game{players_hands: players} = game) when map_size(players) == 1 do
game = %Game{game | current_player_id: nil, players_hands: %{}, current_bid: nil}
{winner, _} = Enum.at(players, 0)
notify_players(game, {:winner, winner})
end
defp start_round(game) do
game = %Game{
game
| players_hands: Map.new(game.players_hands, fn {id, hand} -> {id, Hand.new(hand)} end),
current_bid: {0, 0}
}
game =
if game.phase != game.old_phase do
notify_players(game, {:phase_change, game.phase})
else
game
end
Enum.reduce(
game.players_hands,
game,
fn {id, _}, game -> notify_player(game, id, {:new_hand, game.players_hands[id]}) end
)
end
defp take_instructions(game),
do: {Enum.reverse(game.instructions), %Game{game | instructions: []}}
end