Powered by AppSignal & Oban Pro

Hangman - Agent

hangman/09-agent.livemd

Hangman - Agent

Modulos requeridos

defmodule Hangman.State do
  @moduledoc """
  The Hangman game state
  """

  @enforce_keys [:word, :goal]
  defstruct [
    :word,
    :goal,
    misses: MapSet.new(),
    matches: MapSet.new(),
    limit: 5,
    mask: "_",
    completed?: false
  ]

  @type t :: %__MODULE__{
          word: String.t(),
          goal: MapSet.t(),
          misses: MapSet.t(),
          matches: MapSet.t(),
          limit: pos_integer(),
          mask: String.t(),
          completed?: boolean()
        }

  @doc """
  Creates the initial game state
  """
  @spec new(String.t()) :: t()
  def new(word) when is_binary(word) do
    word = String.downcase(word)
    goal = word |> String.codepoints() |> MapSet.new()
    struct!(__MODULE__, word: word, goal: goal)
  end
end
defmodule Hangman.GameLogic do
  @moduledoc """
  Main logic for the game
  """

  alias Hangman.State

  @doc """
  Returns the game state after the user takes a guess
  """
  @spec guess(String.t(), State.t()) :: State.t()
  def guess(letter, %State{} = state) do
    %{goal: goal, matches: matches, misses: misses, limit: limit} = state

    if MapSet.member?(goal, letter) do
      matches = MapSet.put(matches, letter)
      completed? = MapSet.equal?(matches, goal)
      %{state | matches: matches, completed?: completed?}
    else
      %{state | misses: MapSet.put(misses, letter), limit: limit - 1}
    end
  end
end
defmodule Hangman.Goal.Api do
  @moduledoc """
  Word generator API
  """

  @doc """
  Generates a word, phrase, or sentence.
  """
  @callback generate() :: String.t()
end
defmodule Hangman.Goal do
  @moduledoc """
  Goal (word, phrase, sentence) generator entry point
  """
  @behaviour Hangman.Goal.Api

  @impl true
  def generate do
    client = Application.get_env(:hangman, :goal_generator, Hangman.Goal.DummyGenerator)
    client.generate()
  end
end
defmodule Hangman.Goal.DummyGenerator do
  @behaviour Hangman.Goal.Api

  @impl true
  def generate do
    Enum.random(["hangman", "letterbox", "wheel of fortune"])
  end
end

Agent

Introducción a Agent

Agents ofrecen contenedores simples alrededor de estados. Si lo que necesitas de un proceso es que almacene un estado particular, el módulo Agent es una buena opción.

{:ok, pid} = Agent.start_link(fn -> [] end, name: :milton)
Agent.update(pid, &["eggs" | &1])
Agent.get_and_update(pid, fn state -> {state, ["bacon" | state]} end)
Agent.get(:milton, & &1)
Agent.stop(:milton)
Agent.get(pid, & &1)
Agent.get(:milton, & &1)

Volvamos al juego

Hasta ahora nuestra implementación del juego expone el estado del mismo al jugador, dentro del estado se encuentra la palabra, sentencia u oración objetivo, por lo que no tiene mucho sentido seguir exponiendo dicha información.

Cuando necesitamos mantener una especie de estado en nuestros sistemas, debemos usar alguna abstracción que mantenga el estado por nosotros. Una de esas abstracciones disponibles en Elixir se llama Agent.

defmodule Hangman do
  @moduledoc """
  The famous Hangman game
  """
  use Agent

  alias Hangman.GameLogic
  alias Hangman.Goal
  alias Hangman.State
  alias Hangman.View

  @doc """
  Starts the game
  """
  @spec start_link(atom()) :: Agent.on_start()
  def start_link(player) when is_atom(player) do
    Agent.start_link(
      fn -> State.new(Goal.generate()) end,
      name: player
    )
  end

  @doc """
  Returns the masked word
  """
  @spec get(atom() | pid()) :: String.t()
  def get(player) do
    player
    |> Agent.get(& &1)
    |> View.format_response()
  end

  @doc """
  Lets the user to take a guess
  """
  @spec take_a_guess(atom() | pid(), String.t()) :: String.t()
  def take_a_guess(player, letter) do
    new_state =
      Agent.get_and_update(player, fn state ->
        new_state =
          letter
          |> String.downcase()
          |> GameLogic.guess(state)

        {new_state, new_state}
      end)

    View.format_response(new_state)
  end

  @doc """
  Stops the game for the given player
  """
  def stop(player), do: Agent.stop(player)
end

Actualicemos nuestra capa de presentación para ocultar el estado.

defmodule Hangman.View do
  @moduledoc """
  Presentation layer for the Hangman game
  """

  alias Hangman.State

  @doc """
  Returns a human-friendly response
  """
  @spec format_response(State.t()) :: String.t()
  def format_response(%State{limit: limit, completed?: false} = state) when limit > 0 do
    mask_word(state)
  end

  def format_response(%State{limit: limit, word: word}) when limit > 0 do
    "You won, word was: #{word}"
  end

  def format_response(%State{word: word}) do
    "Game Over, word was: #{word}"
  end

  ## Helpers
  defp mask_word(%{matches: matches, mask: mask, word: word} = _state) do
    if MapSet.size(matches) > 0 do
      matches = Enum.join(matches)
      String.replace(word, ~r/[^#{matches}]/, mask)
    else
      String.replace(word, ~r/./, mask)
    end
  end
end

Veamos como funciona ahora

player = :milton
{:ok, pid} = Hangman.start_link(player)

Nota que si intentamos iniciar un nuevo juego para el mismo jugador, se nos indica que ya se ha iniciado un proceso con el mismo nombre:

Hangman.start_link(player)
IO.inspect(Hangman.get(player))
IO.inspect(Hangman.get(pid))

Nota que podemos usar tanto el PID (Process Identifier) así como un nombre más ameno para hacer referencia al proceso que hemos creado.

Hangman.take_a_guess(player, "a")
Hangman.take_a_guess(player, "h")
Hangman.take_a_guess(player, "m")
Hangman.take_a_guess(player, "n")
Hangman.take_a_guess(player, "g")

Terminemos el juego

Hangman.stop(player)

Si intentamos ejecutar alguna operación para el jugador previo, obtenemos:

Hangman.take_a_guess(player, "z")