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()} | {:announce_next_player, player_id} | {: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{} = 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, ¬ify_player( &2, &1, data ) ) end defp notify_players(game, players, data) do Enum.reduce( players, game, ¬ify_player( &2, &1, data ) ) end defp instructions_and_state(game) do game |> tell_current_player_to_move() |> announce_next_player() |> 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 announce_next_player(%Game{current_player_id: nil} = game), do: game defp announce_next_player(%Game{current_player_id: id, all_players: players} = game), do: notify_players( game, Enum.reject(players, fn player -> player == id end), {:next_player, 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